Mastering Golang for E-commerce Back End Development : Part 2

บทความโดย ผศ.ดร.ณัฐโชติ พรหมฤทธิ์ และ อ.ดร.สัจจาภรณ์ ไวจรรยา
ภาควิชาคอมพิวเตอร์
คณะวิทยาศาสตร์
มหาวิทยาลัยศิลปากร



ใน Part 2 นี้ เราจะก้าวไปอีกขั้นในการเรียนรู้เครื่องมือและแนวคิดที่ทรงพลังของ Go ซึ่งจะช่วยให้คุณสามารถสร้าง Back End ที่มีประสิทธิภาพครับ

Array, Slice และ Map : กล่องเก็บของเล่นที่มีช่องหลายช่อง

Array, Slice และ Map เป็นโครงสร้างข้อมูลพื้นฐานที่สำคัญใน Go ช่วยให้สามารถจัดการข้อมูลได้อย่างมีประสิทธิภาพและมีความปลอดภัย เนื่องจากมีการตรวจสอบ Type อย่างเข้มงวดในขณะ Compile

Go พยายามรวมข้อดีของทั้งภาษา Low-level และ High-level เข้าด้วยกัน โครงสร้างข้อมูลใน Go ถูกออกแบบมาให้เรียบง่าย สะดวกในการใช้งาน แต่มีประสิทธิภาพสูง

Array : กล่องที่มีช่องเท่า ๆ กัน จำนวนแน่นอน

Array เป็นชุดข้อมูลที่มีขนาดคงที่ ประกอบด้วยข้อมูลชนิดเดียวกัน โดยขนาดของ Array ต้องระบุตอนประกาศ และไม่สามารถเปลี่ยนแปลงได้

var fruits [3]string // ระบุขนาดตอนประกาศ

fruits[0] = "แอปเปิ้ล"
fruits[1] = "กล้วย"
fruits[2] = "ส้ม"
fmt.Println(fruits)

numbers := [5]int{1, 2, 3, 4, 5}
fmt.Println(numbers)

เราสามารถเข้าถึง Array ได้หลายรูปแบบ เช่น
1. การเข้าถึงโดยตรงด้วย index
2. การวนลูปด้วย for แบบมาตรฐาน
3. การใช้ range เพื่อวนลูป
4. การหาความยาวของ Array ด้วย len()
5. การเปลี่ยนค่าใน Array

// ตัวอย่างการเข้าถึง Array
    
// 1. การเข้าถึงโดยใช้ index
fmt.Println("ผลไม้ชิ้นแรก:", fruits[0])
fmt.Println("ตัวเลขตัวที่สาม:", numbers[2])

// 2. การวนลูปผ่าน Array ด้วย for แบบมาตรฐาน
fmt.Println("รายการผลไม้:")
for i := 0; i < len(fruits); i++ {
	fmt.Printf("%d. %s\n", i+1, fruits[i])
}

// 3. การใช้ range
fmt.Println("ตัวเลขทั้งหมด:")
for index, value := range numbers {
	fmt.Printf("ตำแหน่ง %d: %d\n", index, value)
}

// 4. การหาความยาวของ Array
fmt.Println("จำนวนผลไม้:", len(fruits))
fmt.Println("จำนวนตัวเลข:", len(numbers))

// 5. การเปลี่ยนค่าใน Array
fruits[1] = "มะม่วง"
fmt.Println("ผลไม้หลังจากเปลี่ยน:", fruits)

Array ใน Go สามารถเก็บข้อมูลได้หลากหลายประเภท รวมทั้ง Function

// ประกาศ array ของ function ที่รับ int และคืนค่า int
var mathFuncs [3]func(int) int

// กำหนด function ให้แต่ละตำแหน่งใน array
mathFuncs[0] = func(x int) int { return x + 1 } // เพิ่ม 1
mathFuncs[1] = func(x int) int { return x * 2 } // คูณ 2
mathFuncs[2] = func(x int) int { return x * x } // ยกกำลัง 2

// ทดลองเรียกใช้ function จาก array
for i, f := range mathFuncs {
	result := f(3)
	fmt.Printf("Function %d: result = %d\n", i, result)

Array ใน Go จะถูกส่งผ่าน Function โดยค่า (By value) ทำให้ต้องคัดลอกข้อมูลทั้งหมด ขณะที่ Array ใน C/C++ และ Java จะถูกส่งผ่านโดยการอ้างอิง (By reference)

func processArray(data [5]int) {
    // ทำงานกับ data
}

func main() {
    a := [5]int{1, 2, 3, 4, 5}
    processArray(a)  // ใช้ได้
    // processArray(a[:3])  // ไม่สามารถทำได้
}

เราส่ง Array ผ่าน Function เมื่อต้องการความแน่นอนของขนาดข้อมูล แต่ไม่ยืดหยุ่นมากนักเมื่อต้องการส่ง Array แค่บางส่วน ดังตัวอย่างด้านบน

Arrays
   - กำหนดขนาดตายตัว
   var ชื่อ [ขนาด]ชนิดข้อมูล
   ชื่อ := [ขนาด]ชนิดข้อมูล{ค่า1, ค่า2, ...}
   
   การใช้งาน
   len(array)          // ความยาวปัจจุบัน
Cheat Sheet!

Slice : กล่องที่ยืดหดได้

Slice คือ Array ที่สามารถปรับขนาดได้แบบไดนามิก ประกอบด้วยข้อมูลชนิดเดียวกัน และไม่ต้องระบุขนาดตอนประกาศ

Slice ของ Go คล้ายกับ List ใน Python และ Array ของ JavaScrip ในแง่ของการปรับขนาดได้

colors := []string{"แดง", "เขียว", "น้ำเงิน"}
fmt.Println(colors)

colors = append(colors, "เหลือง")
fmt.Println(colors)

subColors := colors[1:3]
fmt.Println(subColors)

ชื่อ "Slice" สะท้อนถึงความสามารถในการ "ตัดแบ่ง" หรือ "หยิบส่วน" ของข้อมูลมาใช้งานอย่างยืดหยุ่น

arr := [5]int{1, 2, 3, 4, 5}
slice1 := arr[1:4]  // "ตัดแบ่ง" ส่วนของ array: [2, 3, 4]
slice2 := slice1[1:] // "ตัดแบ่ง" ต่อจาก slice: [3, 4]

เราสามารถสร้าง Slice ที่มีความยาว (Length) และความจุ (Capacity) ตามที่กำหนด โดยใช้คำสั่ง make()

// ใช้ make() สร้าง slice
s1 := make([]int, 5, 10)
fmt.Println("s1:", s1)
fmt.Println("len(s1):", len(s1))
fmt.Println("cap(s1):", cap(s1))

จากตัวอย่าง Slice s1 ถูกสร้างโดยมีค่าเริ่มต้นเป็น 0 ที่บรรจุอยู่ในกล่อง 5 กล่อง แต่มีการเตรียมพื้นที่เผื่อไว้สำหรับการบรรจุกล่องทั้งหมด 10 กล่อง ดังนั้น s1 จึงพร้อมสำหรับใส่ข้อมูลเพิ่มอีก 5 กล่อง

// เปลี่ยนค่าใน slice
s1[0] = 1
s1[1] = 2
s1[2] = 3
fmt.Println("\ns1 หลังเปลี่ยนค่า:", s1)
// ใช้ append() เพิ่มข้อมูล
s1 = append(s1, 4, 5, 6)
fmt.Println("\ns1 หลัง append:", s1)
fmt.Println("len(s1) ใหม่:", len(s1))
fmt.Println("cap(s1) ใหม่:", cap(s1))

ในการสร้าง Slice s2 ให้เป็น Sub-slice ของ Slice s1 ดังตัวอย่างด้านล่าง s2 จะมีความยาว 3 โดยนับจาก 2 ถึง 4 (ไม่รวม 5)

แต่ Capacity ของ Slice s2 จะเริ่มนับจากจุดเริ่มต้นของ Sub-slice ไปจนถึงสิ้นสุดของ Capacity ของ Slice s1

การเปลี่ยนแปลงข้อมูลใน Sub-slice s2 อาจส่งผลต่อ Slice ต้นฉบับได้ เนื่องจาก s2 มีการใช้พื้นที่เก็บข้อมูลบางส่วนร่วมกับ s1

// สร้าง slice จาก slice เดิม
s2 := s1[2:5] // s2 จะมีความยาว 3 นับจาก 2 ถึง 4
fmt.Println("\ns2:", s2)
fmt.Println("len(s2):", len(s2))
fmt.Println("cap(s2):", cap(s2))

เราจะทดลองเพิ่มข้อมูลให้เกิน Capacity ของ s2 (Capacity เดิมเท่ากับ 8) ซึ่งมีข้อมูลบรรจุอยู่ 3 กล่อง

เมื่อพื้นที่เดิมเต็ม ไม่มีที่ว่างเหลือ Go จะเตรียมพื้นที่ใหม่ที่ใหญ่กว่าโดยอัตโนมัติ

// เพิ่มข้อมูลจนล้น capacity
s2 = append(s2, 7, 8, 9, 10, 11, 12)
fmt.Println("\ns2 หลังเพิ่มข้อมูลเกิน capacity:", s2)
fmt.Println("len(s2) ใหม่:", len(s2))
fmt.Println("cap(s2) ใหม่:", cap(s2))

สำหรับ Slice ขนาดเล็ก (Capacity < 1024) เช่น s2 นั้น Go จะเตรียมพื้นที่ใหม่ที่ใหญ่กว่าเดิม 2 เท่า แต่สำหรับ Slice ขนาดใหญ่มาก ๆ (Capacity >= 1024) Go จะเตรียมพื้นที่ใหม่ที่ใหญ่ว่าเดิม 25%

หลังจากนั้นจึงย้ายกล่องจากพื้นที่เดิม (9 กล่อง) มายังพื้นที่ใหม่ที่จุได้ 16 กล่อง

หมายเหตุ จำนวน % ของพื้นที่ใหม่ที่ Go เตรียมให้ ไม่ได้เป็นข้อบังคับ แต่เกิดจากการสังเกตจากพฤติกรรมของมัน

อย่างไรก็ตามการย้ายของจะใช้เวลาและทรัพยากร ดังนั้นถ้าทราบขนาดที่ต้องการล่วงหน้า เราควรใช้คำสั่ง make() เพื่อกำหนด Capacity ไว้ตั้งแต่แรกครับ

เรามักจะส่ง Slice ผ่าน Function เพราะมีความยืดหยุ่นกว่า Array โดย Slice จะถูกส่งผ่าน Function โดยการอ้างอิง (By reference) ทำให้ประหยัดหน่วยความจำ ไม่ต้องคัดลอกข้อมูลทั้งหมดเหมือน Array

// ใช้ Slice
func processSlice(data []int) {
    // ทำงานกับ data
}

func main() {
    s := []int{1, 2, 3, 4, 5}
    processSlice(s)  // ใช้ได้
    processSlice(s[:3])  // ส่งบางส่วนได้
}
Slice
   - ยืดหยุ่น เปลี่ยนแปลงขนาดได้
   ชื่อ := []ชนิดข้อมูล{ค่า1, ค่า2, ...}
   ชื่อ := make([]ชนิดข้อมูล, ความยาว, ความจุ)
   
   การใช้งาน
   append(slice, ค่า)   // เพิ่มค่า
   len(slice)          // ความยาวปัจจุบัน
   cap(slice)          // ความจุ
   slice[start:end]    // สร้าง slice ย่อย
Cheat Sheet!

Map

Map ใช้เก็บข้อมูลแบบ Key-value ทำให้ค้นหาข้อมูลได้เร็ว เหมาะสำหรับการจัดเก็บข้อมูลที่ต้องการการเข้าถึงแบบไม่เรียงลำดับ ซึ่งคล้ายกับ Dictionary ของ Python และ Object หรือ Map ของ Java

Value ของ Map สามารถเป็นข้อมูลประเภทใดก็ได้ ยกเว้น Function โดยขนาดของ Map สามารถเปลี่ยนแปลงได้แบบไดนามิก

ages := map[string]int{
	"Alice": 30,
	"Bob":   25,
}
fmt.Println(ages["Alice"])

ages["Charlie"] = 35
delete(ages, "Bob")

age, exists := ages["David"]
if !exists {
	fmt.Println("ไม่พบข้อมูลของ David")
} else {
	fmt.Println("David อายุ", age)
}

make() จะจัดสรรหน่วยความจำและคืนค่า Map ที่พร้อมใช้งาน โดย make() สามารถระบุขนาดเริ่มต้นได้ เช่น make(map[string]int, 5)

เราสามารถสร้าง Empty Map ได้ทั้งการใช้ make() และไม่ใช้ make() ดังตัวอย่างด้านล่าง เมื่อไม่ใช้ mak(), Go จะสร้าง Map เปล่าโดยไม่มีการจัดสรรหน่วยความจำให้

ทั้งสองวิธีให้ผลลัพธ์เหมือนกันในการใช้งาน แต่อาจมีความแตกต่างในด้านประสิทธิภาพ โดยการใช้ make() อาจมีประสิทธิภาพดีกว่าสำหรับ Map ขนาดใหญ่ เพราะมีการจัดสรรหน่วยความจำให้ล่วงหน้า

// สร้าง Empty Map ด้วย make() โดยไม่ระบุขนาดเริ่มต้น
// Go จะจองพื้นที่หน่วยความจำเริ่มต้นในขนาดเล็กให้
    userAges := make(map[string]int)

    // เพิ่มข้อมูล
    userAges["Alice"] = 30
    userAges["Bob"] = 25
    fmt.Println("userAges:", userAges)
    
// สร้าง Empty Map ด้วย make() พร้อมระบุขนาดเริ่มต้น
    scores := make(map[string]int, 5)

    // เพิ่มข้อมูล
    scores["Math"] = 95
    scores["English"] = 88
    scores["Science"] = 92
    fmt.Println("scores:", scores)
    
// สร้าง Empty Map โดยไม่ใช้ make()
    m := map[string]int{}
    
    // เพิ่มข้อมูล
    m["x"] = 10
    m["y"] = 20
    fmt.Println("m:", m)

เราสามารถสร้าง Map ที่มี Value เป็น Slice ได้ดังตัวอย่างด้านล่าง

// สร้าง Map ที่มี Value เป็น Slice
userHobbies := make(map[string][]string)

// เพิ่มข้อมูล
userHobbies["Alice"] = []string{"reading", "swimming"}
userHobbies["Bob"] = []string{"gaming", "cooking"}
fmt.Println("userHobbies: ", userHobbies)

การใช้ range เพื่อวนลูปผ่าน Map

// วนลูปผ่าน map ด้วย for
for name, hobbies := range userHobbies {
	fmt.Printf("%s's hobbies: ", name)
	for i, hobby := range hobbies {
		if i > 0 {
			fmt.Print(", ")
		}
		fmt.Print(hobby)
	}
	fmt.Println()
}

จากตัวอย่างด้านล่าง เราจะใช้ value, exists := map[key] เพื่อตรวจสอบการมีอยู่ของ key โดย exists จะเป็น true ถ้ามี Key อยู่ใน Map

// การตรวจสอบว่ามี Key อยู่ใน Map หรือไม่
name := "Charlie"
age, exists := userAges[name]
if exists {
	fmt.Printf("%s is %d years old\n", name, age)
} else {
	fmt.Printf("%s is not in the map\n", name)
}

Map มี Function เฉพาะสำหรับการลบข้อมูล คือ delete()

// การลบข้อมูลจาก map
delete(userAges, "Bob")
fmt.Println("userAges after deleting Bob:", userAges)

Map จึงเหมาะกับการจัดเก็บข้อมูลแบบ Key-value และต้องการค้นหาอย่างรวดเร็ว

Maps
   - เก็บข้อมูลแบบ key-value
   ชื่อ := map[ชนิดของkey]ชนิดของvalue{
       key1: value1,
       key2: value2,
   }
   ชื่อ := make(map[ชนิดของkey]ชนิดของvalue)

   การใช้งาน
   map[key] = value     // เพิ่มหรือแก้ไขค่า
   delete(map, key)     // ลบค่า
   value, exists := map[key]  // ตรวจสอบการมีอยู่
Cheat Sheet!

Exercise

ยกตัวอย่าง Use Case ที่เหมาะสมสำหรับการใช้งาน Array ในภาษา Go

ตัวอย่างโค้ด

package main

import "fmt"

const totalSeats = 100

func main() {
    var seats [totalSeats]bool // false = ว่าง, true = จอง

    // จองที่นั่ง
    seats[10] = true
    seats[11] = true

    // ตรวจสอบที่นั่งว่าง
    for i, isReserved := range seats {
        if !isReserved {
            fmt.Printf("ที่นั่งหมายเลข %d ว่าง\n", i+1)
        }
    }
}

Go Quiz 4 (19 ข้อ) ขอให้สนุกกับการทำ Quiz นะครับ

Q&A?

รวม Cheat Sheet Array, Slice และ Map : กล่องเก็บของเล่นที่มีช่องหลายช่อง

Array, Slice และ Map
--------------------

Arrays
   - กำหนดขนาดตายตัว
   var ชื่อ [ขนาด]ชนิดข้อมูล
   ชื่อ := [ขนาด]ชนิดข้อมูล{ค่า1, ค่า2, ...}
   
   การใช้งาน
   len(array)          // ความยาวปัจจุบัน
   
Slice
   - ยืดหยุ่น เปลี่ยนแปลงขนาดได้
   ชื่อ := []ชนิดข้อมูล{ค่า1, ค่า2, ...}
   ชื่อ := make([]ชนิดข้อมูล, ความยาว, ความจุ)
   
   การใช้งาน
   append(slice, ค่า)   // เพิ่มค่า
   len(slice)          // ความยาวปัจจุบัน
   cap(slice)          // ความจุ
   slice[start:end]    // สร้าง slice ย่อย
   
Maps
   - เก็บข้อมูลแบบ key-value
   ชื่อ := map[ชนิดของkey]ชนิดของvalue{
       key1: value1,
       key2: value2,
   }
   ชื่อ := make(map[ชนิดของkey]ชนิดของvalue)

   การใช้งาน
   map[key] = value     // เพิ่มหรือแก้ไขค่า
   delete(map, key)     // ลบค่า
   value, exists := map[key]  // ตรวจสอบการมีอยู่

Pointer : ส่งรีโมทให้คนอื่นใช้ ง่ายกว่าการยกทีวีทั้งเครื่องไปให้

Pointers เป็นเหมือนป้ายบอกทางไปยังที่เก็บข้อมูลในคอมพิวเตอร์ โดย Pointer จะเก็บ Address ในหน่วยความจำ ไม่ใช่ค่าโดยตรง

x := 5
var p *int = &x  // p เก็บที่อยู่ของ x
fmt.Println("ค่าของ x:", x)
fmt.Println("ที่อยู่ของ x:", p)
fmt.Println("ค่าที่ p ชี้ไป:", *p)

จากตัวอย่างด้านบน เราจะใช้ & หรือ Address-of ที่หมายถึง Address ของ x และ * หรือ Dereference สำหรับการประกาศตัวแปร p เป็น Pointer รวมทั้งเพื่อเข้าถึงข้อมูลในตำแหน่งที่ p อ้าง

การประกาศตัวแปร Pointer แบบอัตโนมัติ ด้วย new()

ptr := new(int) // ประกาศแบบอัตโนมัติ
fmt.Println(*ptr)

new(int) จะจองพื้นที่หน่วยความจำสำหรับเก็บค่า int และกำหนดค่าเริ่มต้นให้กับพื้นที่นั้น (สำหรับ int คือ 0) แล้วจึงคืนค่า Pointer ที่ชี้ไปยังพื้นที่หน่วยความจำนั้นให้ ptr

Go ใช้ * สำหรับการประกาศ Pointer และการ Dereferenc รวมทั้งใช้ & สำหรับการรับ Address ของตัวแปรเช่นเดียวกับ C/C++

แต่ Go ไม่มีการคำนวณทางคณิตศาสตร์กับ Pointer ซึ่งทำให้ปลอดภัยกว่า C/C++ โดยต้องแลกกับการทำให้มันไม่มีความยืดหยุ่นเท่า C/C++ ในแง่ของการจัดการหน่วยความจำระดับต่ำ

จึงกล่าวได้ว่า Go พยายามสร้างสมดุลระหว่างความปลอดภัยและประสิทธิภาพในการใช้ Pointer โดยให้ความสามารถในการควบคุมที่มากกว่าภาษาระดับสูงอื่น ๆ แต่ยังคงรักษาความปลอดภัยไว้มากกว่าภาษาระดับต่ำอย่าง C/C++ ครับ

Pointers ช่วยให้ Function doublePointer เปลี่ยนแปลงค่าจริง ที่ไม่ใช่แค่การสำเนาค่าเหมือนใน Function doubleValue ดังตัวอย่างด้านล่าง

func doubleValue(x int) {
    x *= 2
}

func doublePointer(x *int) {
    *x *= 2
}

num := 5
doubleValue(num)
fmt.Println("หลัง doubleValue:", num)  // ยังคงเป็น 5

doublePointer(&num)
fmt.Println("หลัง doublePointer:", num)  // กลายเป็น 10

การส่งผ่าน Pointer จะมีประสิทธิภาพมากกว่าการคัดลอกข้อมูล โดยเฉพาะกับโครงสร้างข้อมูลที่มีขนาดใหญ่ครับ

nil Pointer คือ Pointer ที่ไม่ได้ชี้ไปยัง Address ใด ๆ ในหน่วยความจำ มันเป็นค่าเริ่มต้นสำหรับ Pointer ที่ยังไม่ได้ถูกกำหนดค่า การไม่ตรวจสอบ nil Pointer ก่อนใช้งาน อาจทำให้โปรแกรม Panic ซึ่งเป็นสถานการณ์ที่โปรแกรมเจอข้อผิดพลาดร้ายแรงและไม่สามารถดำเนินการต่อไปได้อย่างปลอดภัย จนทำให้โปรแกรมหยุดทำงานทันที

nil เป็นแนวคิดสำคัญใน Go ที่ใช้ในการจัดการกับสถานะว่างเปล่าหรือไม่มีค่า

var ptr *int // ptr เป็น nil pointer

fmt.Println(*ptr) // จะเกิด panic: runtime error: invalid memory address or nil pointer dereference

ดังนั้นเราควรตรวจสอบ nil ก่อนเข้าถึงค่า Pointer เสมอ

if ptr != nil {
    fmt.Println(*ptr)
} else {
    fmt.Println("Pointer is nil")
}

Exercise

เขียนฟังก์ชันที่รับ Pointer และเปลี่ยนค่าภายใน

Go Quiz 5 (10 ข้อ) ขอให้สนุกกับการทำ Quiz นะครับ

Q&A?

รวม Cheat Sheet Pointer

Pointer
-------

1. สร้าง Pointer
   var p *int  // Pointer to int
   p = &x      // p เก็บที่อยู่ของ x
   
   ptr := new(int) // ประกาศ Pointer แบบอัตโนมัติ

2. เข้าถึงค่าที่ Pointer ชี้ไป
   *p  // ค่าที่ p ชี้ไป

3. สัญลักษณ์
   &  // address-of operator
   *  // dereference operator

4. Pointer กับฟังก์ชัน
   func changeSomething(x *int) {
       *x = newValue  // เปลี่ยนค่าจริงๆ
   }

5. ข้อควรระวัง
   - Pointer ที่เป็น nil อาจทำให้โปรแกรม panic

Struct และ Method : สร้างสิ่งของของเราเอง

เราจะใช้ Struct และ Method สร้างสิ่งของของเรา โดยการสร้างพิมพ์เขียวและกำหนดความสามารถให้กับสิ่งของนั้น

Struct เป็นเหมือนพิมพ์เขียวที่กำหนดว่าสิ่งของของเราจะมีคุณสมบัติอะไรบ้าง

type Cat struct {
    Name  string
    Breed string
    Age   int
}

myCat := Cat{Name: "ตุนตัง", Breed: "มันช์กิ้น", Age: 2}
fmt.Println(myCat)

// การเข้าถึงฟิลด์
fmt.Println("ชื่อแมว:", myCat.Name)

เรามักใช้ Pointer กับ Struct เมื่อต้องการเปลี่ยนแปลงข้อมูล ดังตัวอย่างด้านล่าง

func birthday(p *Cat) {
    p.Age++  // ไม่จำเป็นต้องใช้ (*p).Age
}

myCat := Cat{Name: "ตุนตัง", Breed: "มันช์กิ้น", Age: 2}
birthday(&myCat)
fmt.Println(myCat)  // {ตุนตัง มันช์กิ้น 3}

ส่วน Method เป็น Function ที่เชื่อมโยงกับ Struct ทำให้สิ่งของของเรามีความสามารถ เช่น myCat.Meow() และ myCat.HaveBirthday()

// Value Receiver
func (c Cat) Meow() {
    fmt.Printf("%s กำลังร้อง: เมี้ยว! เมี้ยว!\n", c.Name)
}

// Pointer Receiver
func (c *Cat) HaveBirthday() {
    c.Age++
    fmt.Printf("%s มีอายุ %d ปีแล้ว!\n", c.Name, c.Age)
}
    
myCat.Meow()
myCat.HaveBirthday()

จากตัวอย่างเรานำ (c Cat) ไว้หน้า Meow() และ HaveBirthday() เพื่อสร้าง Method ให้แก่ Type "Cat" ที่เรานิยามเป็น Struct ไว้ก่อนหน้า

ใน Go เรียกพารามิเตอร์พิเศษ (c Cat) นี้ว่า Receiver ซึ่ง Receiver ทำให้เราสามารถเพิ่ม Method ให้กับ Type ต่าง ๆ ได้โดยไม่ต้องแก้ไข Type นั้น ๆ โดยตรง

Receiver นี้ทำให้ Meow() กลายเป็น Method ของ struct "Cat" ที่สามารถเรียกใช้ผ่าน Instance ของ Cat ได้

func (variable Type) MethodName(parameters) {
    // Method body
}

Receiver เป็นกลไกสำคัญใน Go ที่ช่วยให้การเขียนโปรแกรมเชิงวัตถุ (OOP) เป็นไปได้โดยไม่ต้องใช้แนวคิดของ Class แบบดั้งเดิม ทำให้ Code มีความยืดหยุ่นและอ่านง่ายขึ้น

Receiver ใน Go มี 2 ประเภท คือ Value Receiver และ Pointer Receiver

Value Receiver จะทำงานกับสำเนาของ Type เช่น Struct โดยไม่สามารถแก้ไขข้อมูลใน Struct ต้นฉบับได้ ซึ่งเหมาะสำหรับ Method ที่เพียงแค่อ่านข้อมูลหรือมีการคำนวณโดยไม่เปลี่ยนแปลงข้อมูล เช่น Method Meow()

Pointer Receiver จะทำงานกับ Pointer ที่ชี้ไปยัง Type เช่น Struct จริง ทำให้สามารถแก้ไขข้อมูลใน Struct ต้นฉบับได้ ซึ่งเหมาะสำหรับ Method ที่ต้องการแก้ไขข้อมูลต้นฉบับ เช่น Method HaveBirthday() เพราะต้องมีการแก้ไขอายุของแมว

Go อนุญาตให้กำหนด Method แก่ Type ที่เราสร้างขึ้นเอง แม้ไม่ใช่ Struct

type MyInt int

// Pointer receiver
func (m *MyInt) Increment() {
    *m++
}

var num MyInt = 5
num.Increment()            
fmt.Println(num)

จากตัวอย่างด้านบน MyInt เป็น Type ที่สร้างจาก int และมี Method คือ Increment() ที่ประกาศเป็น Pointer Receiver

Composition ใน Go คือการสร้างโครงสร้างข้อมูลที่ซับซ้อนขึ้นโดยการรวมโครงสร้างที่เรียบง่ายเข้าด้วยกัน แทนการใช้การสืบทอด (Inheritance) แบบที่พบในภาษา OOP อื่น ๆ  ซึ่งทำให้ Code ของ Go มีความยืดหยุ่นและนำกลับมาใช้ใหม่ได้ดีกว่า

Struct Embedding เป็นวิธีที่ Go ใช้ในการทำ Composition โดยการนำ Struct หนึ่งไปฝังไว้ในอีก Struct หนึ่ง

type Animal struct {
    Name string
    Age  int
}

func (a Animal) Breathe() {
    fmt.Printf("%s กำลังหายใจ\n", a.Name)
}

type Cat struct {
    Animal  // Embedding Struct Animal, Cat "is-a" Animal
    Breed string
}

func (c Cat) Meow() {
    fmt.Printf("%s กำลังร้อง: เมี้ยว! เมี้ยว!\n", c.Name)
}

func main() {
    myCat := Cat{
        Animal: Animal{Name: "ตุนตัง", Age: 2},
        Breed:  "มันช์กิ้น",
    }
    myCat.Breathe()  // เรียกใช้ method จาก Animal
    myCat.Meow()     // เรียกใช้ method ของ Cat
    fmt.Printf("%s เป็นแมวพันธุ์ %s\n", myCat.Name, myCat.Breed)
}

จากตัวอย่างด้านบน การ Embedding Struct Animal ลงใน Struct Cat ทำให้ myCat สามารถเรียกใช้ Method Breathe() จาก Animal ได้

เราสามารถนำ Struct ที่มีอยู่แล้วมาใช้กับ Struct ใหม่ได้หลาย Struct และสามารถ Embed หลาย Struct ลงใน Struct เดียวได้เช่นกัน

การใช้ Composition ผ่านการทำ Struct Embedding ใน Go จึงช่วยให้เราสร้างโครงสร้างข้อมูลที่ซับซ้อนและยืดหยุ่น รวมทั้งสามารถนำ Code กลับมาใช้ใหม่ได้ดียิ่งขึ้น โดยไม่ต้องพึ่งพาการสืบทอดแบบดั้งเดิม

Exercise

สร้าง Struct "Book" พร้อม Method สำหรับแสดงข้อมูลหนังสือ

Go Quiz 6 (19 ข้อ) ขอให้สนุกกับการทำ Quiz นะครับ

Q&A?

รวม Cheat Sheet Struct และ Method : สร้างสิ่งของของเราเอง

Struct และ Method
-----------------

1. สร้าง Struct
   type ชื่อStruct struct {
       Field1 Type1
       Field2 Type2
   }

2. สร้างตัวแปร Struct
   var1 := ชื่อStruct{Field1: value1, Field2: value2}
   var2 := &ชื่อStruct{Field1: value1, Field2: value2}  // pointer

3. เข้าถึงฟิลด์
   var1.Field1 = newValue

4. สร้าง Method (Value Receiver)
   func (v ชื่อStruct) ชื่อMethod() ReturnType {
       // โค้ดของ method
   }

5. สร้าง Method (Pointer Receiver)
   func (v *ชื่อStruct) ชื่อMethod() ReturnType {
       // โค้ดของ method (สามารถเปลี่ยนแปลงค่าใน struct ได้)
   }

6. เรียกใช้ Method
   var1.ชื่อMethod()

7. Embedding Structs
   type ชื่อStructใหม่ struct {
       ชื่อStructเดิม
       Fieldใหม่ Type
   }

Bonus Pair Programming with GO : 1

Interface : ทำความรู้จักกับเพื่อนใหม่

Interfaces ใน Go เป็นเหมือน "สัญญา" ที่บอกว่าสิ่งของต่าง ๆ สามารถทำอะไรได้บ้าง โดยไม่ต้องสนใจว่าเป็นสิ่งของชนิดไหนจริง ๆ

Interface กำหนด ชุดของ Method ที่ต้องมี โดยไม่ระบุวิธีการทำงานภายใน ทำให้สิ่งของต่าง ๆ สามารถ "ปฏิบัติตาม" Interface ได้ตราบใดที่มี Method ตรงตามที่กำหนด มันจึงช่วยแยกการออกแบบ (What) ออกจากการนำไปใช้ (How หรือ Implementation) ออกจากกัน

ในตัวอย่างนี้ Soundmaker เป็น Interface ที่ "กำหนดว่า" หรือ "สัญญาว่า" อะไรก็ตามที่ทำเสียงได้ ต้องมี Method "MakeSound()" โดยไม่สนใจว่าจะเป็นสุนัขหรือแมว ทำให้เราสามารถใช้ Function "AnimalSound()" รับ Parameter ที่มี Type เป็นสัตว์ชนิดใดก็ได้ที่ "ทำเสียง" ได้

// ประกาศ Interface ระบุว่า Soundmaker ต้องสามารถทำอะไรได้บ้าง
// ไม่ได้บอกวิธีการทำงาน เพียงแค่กำหนดว่าต้องมีความสามารถอะไร (What)

type Soundmaker interface {
    MakeSound() string
}

// การนำไปใช้ (How หรือ Implementation) แสดงวิธีการที่ Dog และ Cat จะทำเสียง
type Dog struct{}
type Cat struct{}

func (d Dog) MakeSound() string {
    return "Woof!"
}

func (c Cat) MakeSound() string {
    return "Meow!"
}

// (Usage) การใช้งาน Interface Soundmaker เป็น Parameter
func AnimalSound(s Soundmaker) {
    fmt.Println(s.MakeSound())
}

func main() {
    dog := Dog{}
    cat := Cat{}
    
    AnimalSound(dog) // พิมพ์ "Woof!"
    AnimalSound(cat) // พิมพ์ "Meow!"
}

เราส่ง dog และ cat เข้าไปใน AnimalSound() ได้โดยไม่ต้องเปลี่ยนแปลง Function รวมทั้งสามารถเพิ่มสัตว์ชนิดใหม่ที่ทำเสียงได้โดยไม่ต้องแก้ไข AnimalSound() เพราะ AnimalSound() ไม่จำเป็นต้องรู้รายละเอียดการทำงานภายในของสัตว์แต่ละประเภท

Empty Interface ทำให้ Function สามารถรับค่าได้ทุกชนิด

Empty Interface ใน Go เขียนแทนด้วย interface{} หรือในเวอร์ชันใหม่ ๆ ของ Go สามารถใช้ any ได้

Empty Interface เป็น Interface ที่ไม่มี Method ใด ๆ กำหนดไว้ ทำให้ทุก ๆ Type ใน Go Implement Interface นี้โดยอัตโนมัติ

func printAnything(v interface{}) {
    fmt.Printf("Type: %T, Value: %v\n", v, v)
}

func main() {
    printAnything(42)
    printAnything("Hello")
    printAnything(true)
    printAnything([]int{1, 2, 3})
}

อย่างไรก็ตามการใช้ Empty Interface จะทำให้ Go ไม่มีการตรวจสอบ Type ขณะ Compile เราจึงต้องระมัดระวังเรื่องการเกิด Panic ครับ

// Function รับ Empty Interface เป็น Parameter
func doubleValue(v interface{}) {
	// พยายามคูณค่าด้วย 2 โดยไม่ตรวจสอบ Type
	result := v.(int) * 2
	fmt.Println("Double value:", result)
}

// ทดลองเรียก Function กับข้อมูลต่าง ๆ
doubleValue(5)       // ทำงานได้
doubleValue("Hello") // เกิด Panic

การใช้ Empty Interface ทำให้เราต้องตรวจสอบ Type ของข้อมูลเอง เพื่อไม่ให้เกิด Panic และสามารถนำข้อมูลนั้นออกมาใช้ได้อย่างถูกต้อง

เหมือนกับการที่เรามีกล่องใส่ของหลาย ๆ ชนิด แต่เราอยากหยิบแค่ยาลดกรดออกมา เราจึงต้องตรวจสอบมันก่อนว่าสิ่งที่หยิบออกมาเป็นยาลดกรดหรือเปล่า แทนที่จะพยายามนำมากินแล้วกลายเป็นยาลดน้ำตาลในเลือด การกินผิดอาจทำให้น้ำตาลในเลือดต่ำหรือกรดไหลย้อนไม่ดีขึ้น

ใน Go เราสามารถใช้ Type Assertion หรือ Type Switch เพื่อตรวจสอบ Type ก่อนเข้าถึงค่าจริง ๆ

การใช้งาน Type Assertion

func doubleValue(v interface{}) {
    if intValue, ok := v.(int); ok {
        result := intValue * 2
        fmt.Println("Double value:", result)
    } else {
        fmt.Printf("Cannot double value of type %T\n", v)
    }
}

doubleValue(5)      // ทำงานได้
doubleValue("Hello") // ไม่เกิด Panic

intValue, ok := v.(int) คือการพยายามแปลงค่า v ให้เป็น int ถ้าแปลงได้สำเร็จ ค่า int จะถูกเก็บใน intValue และ ok จะเป็น true แต่ถ้า v ไม่ใช่ int ตัวแปร intValue จะมีค่าเป็น 0 และ ok จะเป็น false

คำสั่ง if intValue, ok := v.(int); ok คือการเช็คค่า ok ที่เกิดจากการพยายามแปลงค่า v ถ้า ok เป็น true เราจะสามารถใช้ intValue ได้ แต่ถ้า ok เป็น false เราจะแสดงข้อความ "Cannot double value of type..." แทนที่จะปล่อยให้ Program เกิด Panic

การใช้งาน Type Switch

func doubleValue(v interface{}) {
    switch val := v.(type) {
    case int:
        result := val * 2
        fmt.Println("Double value:", result)
    case float64:
        result := val * 2
        fmt.Println("Double value:", result)
    default:
        fmt.Printf("Cannot double value of type %T\n", v)
    }
}

doubleValue(5)        // ทำงานได้
doubleValue(3.14)     // ทำงานได้กับ float64
doubleValue("Hello")  // ไม่เกิด panic

Type Switch ทำให้เราสามารถจัดการกับ Type หลาย Type ได้สะดวก โดย Type อื่น ๆ ที่ไม่ได้ระบุใน case มันจะเข้า Default Case

ทั้ง Type Assertion และ Type Switch ทำให้ Code ของเราปลอดภัยขึ้นและสามารถจัดการกับข้อมูลที่ไม่ตรงตาม Type ที่คาดหวังได้อย่างเหมาะสมครับ

อย่างไรก็ตามเราควรใช้ Empty Interface เมื่อจำเป็นจริง ๆ เท่านั้น เช่น เมื่อต้องการความยืดหยุ่นสูงมาก หรือเมื่อทำงานกับข้อมูลที่ไม่รู้ Type ล่วงหน้า เช่น การ Parse JSON

เราสามารถทำ Embedding กับ Interface ได้เช่นเดียวกับการทำ Struct Embedding

Interface Embedding คือการรวม Interface หลาย ๆ Interface เข้าด้วยกันเพื่อสร้าง Interface ใหม่ที่มีความสามารถรวมกัน

// Interface สำหรับสิ่งมีชีวิตที่เคลื่อนไหวได้
type Mover interface {
    Move() string
}

// Interface สำหรับสิ่งมีชีวิตที่ส่งเสียงได้
type Sounder interface {
    MakeSound() string
}

// Interface สำหรับสัตว์เลี้ยง
type Pet interface {
    GetName() string
}

// รวม Interfaces เข้าด้วยกันเป็น Animal
type Animal interface {
    Mover
    Sounder
    Pet
}

// สร้าง Struct สำหรับแมว
type Cat struct {
    Name string
}

// Implement Move สำหรับ Cat
func (c Cat) Move() string {
    return c.Name + " is sneaking around"
}

// Implement MakeSound สำหรับ Cat
func (c Cat) MakeSound() string {
    return c.Name + " says: Meow!"
}

// Implement GetName สำหรับ Cat
func (c Cat) GetName() string {
    return c.Name
}

// Function ที่รับ Animal Interface
func DescribeAnimal(a Animal) {
    fmt.Println("Name:", a.GetName())
    fmt.Println("Movement:", a.Move())
    fmt.Println("Sound:", a.MakeSound())
}

myCat := Cat{Name: "ตุนตัง"}
DescribeAnimal(myCat)

Interface Embedding หรือการใช้ Composition ของ Interface ทำให้สามารถออกแบบ Interface ได้อย่างยืดหยุ่น โดยการแยกความสามารถออกเป็นส่วน ๆ เช่น ถ้าเราต้องการเพิ่มความสามารถของ Animal เราอาจจะสร้าง Interface ใหม่ แล้วเพิ่มเข้าไปใน Interface "Animal" ครับ

Function ใน Go สามารถส่งค่ากลับเป็น Interface ได้ เช่น Function "getRandomAnimal()" ในตัวอย่างถัดไป ที่จะส่งค่ากลับเป็น Interface Soundmaker ซึ่งจะทำให้มันสามารถส่งค่ากลับจริง ๆ เป็น Type Dog, Cat หรือ Cow ซึ่งมีการ Implement Soundmaker Interface (Implement Method MakeSound)

ทำให้ในอนาคตเราสามารถเพิ่มสัตว์ชนิดใหม่ (ซึ่งมีการ Implement Soundmaker Interface) ที่มีความสามารถแตกต่างกันได้ตามต้องการ โดยไม่ต้องเปลี่ยนแปลง Signature ของ Function "getRandomAnimal()"

package main

import (
    "fmt"
    "math/rand"
)

// ประกาศ Interface
type Soundmaker interface {
    MakeSound() string
}

// การนำไปใช้ (Implementation)
type Dog struct{}
type Cat struct{}
type Cow struct{}

func (d Dog) MakeSound() string {
    return "Woof!"
}

func (c Cat) MakeSound() string {
    return "Meow!"
}

func (c Cow) MakeSound() string {
    return "Moo!"
}

// Function ที่ส่งค่ากลับเป็น Interface Soundmaker
func getRandomAnimal() Soundmaker {
    randomNumber := rand.Intn(3)
    switch randomNumber {
    case 0:
        return Dog{}
    case 1:
        return Cat{}
    default:
        return Cow{}
    }
}

// การใช้งาน Interface Soundmaker เป็น Parameter
func AnimalSound(s Soundmaker) {
    fmt.Println(s.MakeSound())
}

func main() {
    // ใช้ Function ที่ส่งค่ากลับเป็น Interface
    for i := 0; i < 5; i++ {
        animal := getRandomAnimal()
        fmt.Printf("สัตว์ตัวที่ %d ส่งเสียง: ", i+1)
        AnimalSound(animal)
    }

    // ตัวอย่างการใช้ type assertion
    randomAnimal := getRandomAnimal()
    switch v := randomAnimal.(type) {
    case Dog:
        fmt.Println("นี่คือหมา!")
    case Cat:
        fmt.Println("นี่คือแมว!")
    case Cow:
        fmt.Println("นี่คือวัว!")
    default:
        fmt.Printf("ไม่รู้จักสัตว์ชนิดนี้: %T\n", v)
    }
}

Exercise

สร้าง Interface "Mover" ที่มี method Move() และ Implement ด้วย Struct ต่าง ๆ

Go Quiz 7 (23 ข้อ) ขอให้สนุกกับการทำ Quiz นะครับ

Q&A?

รวม Cheat Sheet Interface : ทำความรู้จักกับเพื่อนใหม่

Interface
---------

1. ประกาศ Interface
   type ชื่อInterface interface {
       ชื่อMethod1() ReturnType1
       ชื่อMethod2(ParamType) ReturnType2
   }

2. การ Implement Interface
   - Struct ที่มี Method ตรงกับที่ Interface กำหนดจะ Implement Interface โดยอัตโนมัติ
   - ไม่ต้องประกาศว่า Implement เหมือนภาษาอื่น

3. การใช้ Interface เป็น Parameter:
   func ชื่อฟังก์ชัน(param ชื่อInterface) {
       // ใช้งาน methods ของ interface
   }

4. Empty Interface
   interface{}  // รับได้ทุกชนิดข้อมูล

5. Type Assertion
   value, ok := interfaceVar.(ConcreteType)

6. Type Switch
   switch v := interfaceVar.(type) {
   case Type1:
       // v เป็น Type1
   case Type2:
       // v เป็น Type2
   default:
       // ไม่ตรงกับ Type ที่ระบุ
   }

7. Best Practices:
   - ออกแบบ Interface ให้เล็กและมีจุดประสงค์ชัดเจน
   - ใช้ Composition ของ Interfaces เพื่อสร้าง Interface ที่ซับซ้อนขึ้น

Package และ Module : แต่ละแผนกมีหน้าที่เฉพาะ และ Project ใหญ่ที่ประกอบด้วยหลายแผนก

Package และ Module เป็นวิธีจัดระเบียบและแบ่งปัน Code

โดย Package คือ "กลุ่มของไฟล์ Go ที่อยู่ใน Folder เดียวกัน" และ "ทำงานเกี่ยวข้องกัน"

// ไฟล์ math/calculator.go
package math

func Add(a, b int) int {
    return a + b
}

// ไฟล์ main.go
package main

import (
    "fmt"
    "myproject/math"
)

func main() {
    result := math.Add(5, 3)
    fmt.Println("5 + 3 =", result)
}

จากตัวอย่างนี้จะเป็นการสร้าง Package "math" ที่เก็บใน Folder "myproject" โดยโครงสร้างของ Folder และ File จะเป็นดังนี้

myproject/
├── math/
│   └── calculator.go
└── main.go

จากตัวอย่างด้านบน เราจะสร้าง myproject เป็น Folder หลักของ Project และสร้าง Folder ย่อย math สำหรับ Package "math"

Go จะใช้ชื่อ Folder ในการอ้างอิงเวลา Import เราจึงไม่จำเป็นต้องตั้งชื่อ Folder เป็นชื่อเดียวกันกับชื่อ Package แต่โดยทั่วไปแล้วการตั้งชื่อ Folder ให้ตรงกับชื่อ Package นั้นเป็นแนวปฏิบัติที่ดี ซึ่งจะช่วยให้ Code อ่านง่ายขึ้นครับ

ใน Folder ย่อย math เราจะสร้าง File "calculator.go" ที่มี Function "Add" โดยใน File  "calculator.go" จะมีเนื้อหาดังนี้

// ไฟล์ math/calculator.go
package math

func Add(a, b int) int {
    return a + b
}

เราประกาศว่า Code นี้อยู่ใน Package ชื่อ "math" ด้วยคำสั่ง package math ซึ่งใน Folder "math" สามารถมีไฟล์ได้หลายไฟล์ แต่ ทุกไฟล์จะต้องประกาศชื่อ Package เดียวกัน

Function "Add" ต้อง ขึ้นต้นด้วยตัวพิมพ์ใหญ่ เพื่อให้สามารถ Export ได้ ส่วนชื่อ Function ที่ขึ้นต้นด้วยตัวพิมพ์เล็กจะใช้ได้เฉพาะภายใน Package เท่านั้น

ใน Folder "myproject" เราจะสร้าง File "main.go" เป็นไฟล์หลักที่มีเนื้อหาดังนี้

// ไฟล์ main.go
package main

import (
    "fmt"
    "myproject/math"
)

func main() {
    result := math.Add(5, 3)
    fmt.Println("5 + 3 =", result)
}

ใน Go เราต้องระบุว่า Code ส่วนนี้เป็น Package หลักของ Program ซึ่งจำเป็นสำหรับการสร้างไฟล์ Executable ด้วยคำสั่ง package main

package main ในไฟล์ main.go จะไม่สามารถเปลี่ยนเป็นชื่ออื่นได้ ถ้าต้องการให้ Program ของเราเป็น Program ที่สามารถรันได้เมื่อเรา Build Program

เมื่อเรา Build Program ด้วยคำสั่ง go build แล้ว Go Compiler จะมองหา package main รวมทั้ง func main() เพื่อกำหนดจุดเป็นเริ่มต้นของ Program ดังนั้น func main() ต้องอยู่ใน package main เท่านั้น เมื่อต้องการ Build Program

import คือ ส่วนนำเข้า Package ที่จะใช้ ซึ่ง main.go จะใช้งาน Package "fmt" ที่เป็น Package มาตรฐานสำหรับการ Input/Output และ Package "math" ที่เราสร้างขึ้น

import (
    "fmt"
    "myproject/math"
)

ใน func main() มีการเรียกใช้ Function "Add" จาก Package "math" และ Function Println จาก Package "fmt" เพื่อแสดงผลลัพธ์

func main() {
    result := math.Add(5, 3)
    fmt.Println("5 + 3 =", result)
}

Module คือ กลุ่มของ Package ที่เกี่ยวข้องกัน ซึ่งจะถูกพัฒนาและถูกจัดการ Version ร่วมกัน

เราจะเพิ่ม Package ใหม่ชื่อ "utils" ที่มี Function สำหรับการแปลงตัวเลขเป็นคำอ่านภาษาไทย ดังนี้

1. สร้าง Folder ใหม่ชื่อ "utils" เป็น Folder ย่อยใน myproject

myproject/
├── math/
│   └── calculator.go
├── utils/
│   └── converter.go
└── main.go

2. สร้างไฟล์ "converter.go" ใน Folder "utils"

// ไฟล์ utils/converter.go
package utils

func NumberToThai(num int) string {
    thaiNumbers := []string{"ศูนย์", "หนึ่ง", "สอง", "สาม", "สี่", "ห้า", "หก", "เจ็ด", "แปด", "เก้า"}
    if num >= 0 && num <= 9 {
        return thaiNumbers[num]
    }
    return "ไม่สามารถแปลงได้"
}

3. แก้ไขไฟล์ main.go เพื่อเรียกใช้งาน Package ใหม่

// ไฟล์ main.go
package main

import (
    "fmt"
    "myproject/math"
    "myproject/utils"
)

func main() {
    result := math.Add(5, 3)
    fmt.Println("5 + 3 =", result)

    thaiNumber := utils.NumberToThai(result)
    fmt.Println("ผลลัพธ์เป็นภาษาไทย:", thaiNumber)
}

ดังนั้นในตัวอย่างนี้ ชื่อ Module ของเรา คือ myproject

เพื่อกำหนด Module อย่างเป็นทางการ เราจะสร้างไฟล์ go.mod ใน Folder "myproject" โดยใช้คำสั่ง go mod init

go mod init myproject

หลังจากรันคำสั่ง go mod init แล้ว Go จะสร้างไฟล์ go.mod ใน Folder "myproject"

.
├── go.mod
├── main.go
├── math
│   └── calculator.go
├── myproject
└── utils
    └── converter.go

ไฟล์ go.mod ใน Folder "myproject" มีเนื้อหาดังตัวอย่างต่อไปนี้

module myproject

go 1.21.0
go.mod

การมี Module อย่างเป็นทางการ จะช่วยในการจัดการ Dependency และทำให้การ Import Package ภายใน Project เป็นไปอย่างถูกต้อง ซึ่งเป็นไปตามหลักการออกแบบและพัฒนา Software แบบ 12-Factor App ที่เราได้เรียนมาในอาทิตย์ก่อนหน้าครับ

หลังจากสร้างไฟล์ go.mod แล้ว เราสามารถ Build Program โดยใช้คำสั่ง go build อย่างสั้น ๆ ได้ ซึ่ง go build จะ Compile Code แล้วสร้างไฟล์ Executable ชื่อ "myproject" ที่สามารถรันบน macOS ด้วยคำสั่ง  ./myproject ตามตัวอย่างต่อไปนี้

Go มี Standard Library ที่ครอบคลุมและมีประสิทธิภาพมากมาย เช่น

  1. fmt สำหรับการ Input/Output
  2. time จัดการเวลาและวันที่
  3. encoding/json เข้าและถอดรหัส JSON
  4. database/sql ทำงานกับฐานข้อมูล SQL
  5. errors สำหรับการ Error
  6. testing สำหรับ Unit Testing

การใช้งาน Standard Library เหล่านี้ เราเพียง Import มันมายัง Go Code ตามต้องการ โดยไม่ต้องติดตั้ง Library เพิ่ม

import (
    "database/sql"
    "fmt"
)

แต่เมื่อต้องการใช้งาน Module ภายนอกที่ไม่ได้อยู่ใน Standard Library ของ Go เช่น Web Framework "gin" เราต้องใช้คำสั่ง go get

go get github.com/gin-gonic/gin

ซึ่ง Go จะมีการเพิ่ม Dependency ในไฟล์ go.mod ดังตัวอย่างต่อไปนี้

module myproject

go 1.21.0

require (
	github.com/bytedance/sonic v1.11.6 // indirect
	github.com/bytedance/sonic/loader v0.1.1 // indirect
	github.com/cloudwego/base64x v0.1.4 // indirect
	github.com/cloudwego/iasm v0.2.0 // indirect
	github.com/gabriel-vasile/mimetype v1.4.3 // indirect
	github.com/gin-contrib/sse v0.1.0 // indirect
	github.com/gin-gonic/gin v1.10.0 // indirect
	github.com/go-playground/locales v0.14.1 // indirect
	github.com/go-playground/universal-translator v0.18.1 // indirect
	github.com/go-playground/validator/v10 v10.20.0 // indirect
	github.com/goccy/go-json v0.10.2 // indirect
	github.com/json-iterator/go v1.1.12 // indirect
	github.com/klauspost/cpuid/v2 v2.2.7 // indirect
	github.com/leodido/go-urn v1.4.0 // indirect
	github.com/mattn/go-isatty v0.0.20 // indirect
	github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
	github.com/modern-go/reflect2 v1.0.2 // indirect
	github.com/pelletier/go-toml/v2 v2.2.2 // indirect
	github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
	github.com/ugorji/go/codec v1.2.12 // indirect
	golang.org/x/arch v0.8.0 // indirect
	golang.org/x/crypto v0.23.0 // indirect
	golang.org/x/net v0.25.0 // indirect
	golang.org/x/sys v0.20.0 // indirect
	golang.org/x/text v0.15.0 // indirect
	google.golang.org/protobuf v1.34.1 // indirect
	gopkg.in/yaml.v3 v3.0.1 // indirect
)

อย่างไรก็ตาม Dependency เหล่านี้จะถูก Comment เป็น // indirect เพราะมีการติดตั้ง Moddule "gin" ก่อนการใช้งานใน Go Code

นอกจากการเพิ่ม Dependency ในไฟล์ go.mod แล้ว Go ยังสร้างไฟล์ go.sum ที่เก็บ Hash (จาก Hash Algorithm เช่น SHA-256) ของแต่ละ Module ที่ Project ของเราใช้ เพื่อตรวจสอบความถูกต้องของ Module ที่ Download มา

โครงสร้างของ Hash ในแต่ละ Module ของไฟล์ go.sum

<module> <version> <hash>
<module> <version>/go.mod <hash>

ตัวอย่าง go.sum เมื่อติดตั้ง Module "gin"

github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=

ทุกครั้งที่ Download หรือใช้ Module ใน Version นั้น ๆ Go จะตรวจสอบ Hash ใน go.sum กับ Hash ที่คำนวณได้จาก Module ที่ Download หรือใช้งานในปัจจุบัน (จาก Cache) ถ้า Hash ไม่ตรงกัน Go จะแจ้งเตือนและหยุดการทำงาน

วิธีนี้ช่วยให้มั่นใจได้ว่า Module ที่ใช้ ยังคงเหมือนเดิมกับตอนที่ Download หรือ Update ครั้งแรก ไม่มีการเปลี่ยนแปลงโดยไม่ได้ตั้งใจหรือจะเป็นอันตรายจากการพยายามแก้ไข Module โดยไม่เปลี่ยน Version อย่างถูกต้อง

สมมติว่าเราเพิ่ม Code API ด้วย Web Framework "gin" ใน func main()แล้วกด Save, VS Code จะแทรกการ Import "github.com/gin-gonic/gin" และ "net/http" ลงใน Code ให้โดยอัตโนมัติ

// ไฟล์ main.go
package main

import (
	"fmt"
	"myproject/math"
	"myproject/utils"
	"net/http"

	"github.com/gin-gonic/gin"
)

func main() {
	result := math.Add(5, 3)
	fmt.Println("5 + 3 =", result)

	thaiNumber := utils.NumberToThai(result)
	fmt.Println("ผลลัพธ์เป็นภาษาไทย:", thaiNumber)

	// สร้าง Gin engine
	r := gin.Default()

	// กำหนด route
	r.GET("/ping", func(c *gin.Context) {
		c.JSON(http.StatusOK, gin.H{
			"message": "pong",
		})
	})

	// กำหนด route ที่รับพารามิเตอร์
	r.GET("/hello/:name", func(c *gin.Context) {
		name := c.Param("name")
		c.String(http.StatusOK, "Hello %s", name)
	})

	// รัน server ที่ port 8080
	r.Run(":8080")
}

เราสามารถใช้คำสั่ง go mod tidy เพื่อทำให้ไฟล์ go.mod และ go.sum สะท้อนสถานะจริงของ Project โดยรักษาความสอดคล้องและความถูกต้องของ Dependency

go mod tidy

go mod tidy จะช่วยจัดการและ Update สถานะของ Dependency ในไฟล์ go.mod โดยแยก github.com/gin-gonic/gin ออกมาไว้ต่างหาก

module myproject

go 1.21.0

require github.com/gin-gonic/gin v1.10.0

require (
	github.com/bytedance/sonic v1.11.6 // indirect
	github.com/bytedance/sonic/loader v0.1.1 // indirect
	github.com/cloudwego/base64x v0.1.4 // indirect
	github.com/cloudwego/iasm v0.2.0 // indirect
	github.com/gabriel-vasile/mimetype v1.4.3 // indirect
	github.com/gin-contrib/sse v0.1.0 // indirect
	github.com/go-playground/locales v0.14.1 // indirect
	github.com/go-playground/universal-translator v0.18.1 // indirect
	github.com/go-playground/validator/v10 v10.20.0 // indirect
	github.com/goccy/go-json v0.10.2 // indirect
	github.com/json-iterator/go v1.1.12 // indirect
	github.com/klauspost/cpuid/v2 v2.2.7 // indirect
	github.com/leodido/go-urn v1.4.0 // indirect
	github.com/mattn/go-isatty v0.0.20 // indirect
	github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
	github.com/modern-go/reflect2 v1.0.2 // indirect
	github.com/pelletier/go-toml/v2 v2.2.2 // indirect
	github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
	github.com/ugorji/go/codec v1.2.12 // indirect
	golang.org/x/arch v0.8.0 // indirect
	golang.org/x/crypto v0.23.0 // indirect
	golang.org/x/net v0.25.0 // indirect
	golang.org/x/sys v0.20.0 // indirect
	golang.org/x/text v0.15.0 // indirect
	google.golang.org/protobuf v1.34.1 // indirect
	gopkg.in/yaml.v3 v3.0.1 // indirect
)

เมื่อใช้คำสั่ง go mod why เพื่อดูว่าทำไม Dependency นี้ถึงจำเป็น จะพบว่ามันไม่ถูกใช้ จาก Dependency อื่น จึงไม่มีการ Comment เป็น // indirect อีกต่อไป

go mod why github.com/gin-gonic/gin

แต่ถ้าใช้คำสั่ง go mod why กับ Indirect Dependency เช่น github.com/bytedance/sonic เราจะเห็นว่า Indirect Dependency นี้ถูกใช้โดย Dependency ใดบ้าง

go mod why github.com/bytedance/sonic

เรามักจะใช้คำสั่ง go get -u เพื่อ Update Dependency ให้เป็น Version ล่าสุด ร่วมกับคำสั่ง go mod tidy เพื่อทำความสะอาด และจัดการความสอดคล้องของ Dependency เช่น

go get -u github.com/gin-gonic/gin
go mod tidy

แต่การอัพเดท Dependency อาจทำให้เกิดการเปลี่ยนแปลงที่ไม่คาดคิดใน Code เราจึงควรทดสอบ Program หลังจากรัน go mod tidy เสมอ

Exercise

สร้าง Package ง่าย ๆ และ Import มาใช้ในโปรแกรมหลัก

Go Quiz 8 (20 ข้อ) ขอให้สนุกกับการทำ Quiz นะครับ

Q&A?

รวม Cheat Sheet Package และ Module

Package และ Module
------------------

1. สร้าง Package
   - สร้าง Folder ใหม่
   - สร้างไฟล์ .go ใน Folder นั้น
   - เริ่มไฟล์ด้วย package ชื่อแพ็คเกจ

2. Import Package
   import "path/to/package"
   import (
       "package1"
       "package2"
   )

3. Export/Unexport
   - Function หรือตัวแปรที่ขึ้นต้นด้วยตัวพิมพ์ใหญ่จะ Export ได้
   - ที่ขึ้นต้นด้วยตัวพิมพ์เล็กจะใช้ได้เฉพาะภายใน Package

4. สร้าง Module
   go mod init github.com/username/module

5. เพิ่ม Dependency
   go get package_url

6. ทำไม Dependency นี้จึงจำเป็น
   go mod why
7. Update Dependency
   go get -u
   go mod tidy

8. ไฟล์สำคัญ
   - go.mod รายการ Dependency
   - go.sum ตรวจสอบความถูกต้องของ Dependency

9. Build
   go build

Error, Defer, Panic และ Recover : จัดการกับปัญหา

การจัดการกับปัญหาหรือข้อผิดพลาด เป็นส่วนสำคัญในการสร้าง Program ที่มีความทนทานและน่าเชื่อถือ

Go มีแนวทางที่เรียบง่ายและชัดเจนในการจัดการข้อผิดพลาด โดยไม่มี Exception แบบที่พบในภาษาอื่น

Go มี Built-in Interface "error" (error ไม่มี s) ที่มี Method เดียว คือ Method "Error()" ซึ่งไม่รับพารามิเตอร์ และคืนค่าเป็น string ที่อธิบายข้อผิดพลาดโดยไม่จำเป็นต้อง Import อะไรเพิ่มเติมเพื่อใช้งาน

// การประกาศ Interface error เป็น Built-in Interface ใน Go
type error interface {
    Error() string
}

และ Go มี Standard Library "errors" สำหรับการจัดการข้อผิดพลาด

import "errors"

Interface "error" จะถูกส่งกลับจาก Function "New()" ใน Library "errors"

// Function "New" และการ Implement Method Error() แก่ errorString ใน Library "errors"

package errors

func New(text string) error {
	return &errorString {
		text
	}
}

type errorString struct {
	s string
}

func(e * errorString) Error() string {
	return e.s
}

เราจึงสามารถเขียน Function ที่สามารถจัดการกับข้อผิดพลาดที่อาจเกิดขึ้นได้โดยการส่งค่ากลับเป็น Interface "error" เช่น

// ประกาศ Function "divide" ให้ส่งค่ากลับเป็น Interface "error"
func divide(a, b float64) (float64, error) {
    if b == 0 {
        return 0, errors.New("หารด้วยศูนย์ไม่ได้")
    }
    return a / b, nil
}

การตรวจสอบและจัดการ Error
Function "divide" จะคืนค่า 2 ค่า ค่าแรกเป็น float64 ซึ่งเป็นค่าผลลัพธ์ที่ต้องการ และค่าที่สองเป็น error จาก Built-in Interface

ถ้า Function ทำงานสำเร็จ มันจะคืนค่าผลลัพธ์และ nil แต่ถ้าเกิดข้อผิดพลาด มันจะคืนค่า 0 และ error ที่ไม่ใช่ nil ด้วยคำสั่ง errors.New("หารด้วยศูนย์ไม่ได้")

หมายเหตุ errors คือชื่อ Library และ New() คือ Function ใน Library "errors"

result, err := divide(10, 0)
if err != nil {
    fmt.Println("เกิดข้อผิดพลาด:", err)
} else {
    fmt.Println("ผลลัพธ์:", result)
}

การมี Built-in Interface "error" จะทำให้เราสามารถสร้าง Error แบบกำหนดเองได้ เช่น ด้วยการ Implement Method Error() แก่ DivisionError ที่มี 2 Field คือ dividend และ  divisor

package main
import (
    "fmt"
)

// Implement Error Interface (Implement Method Error())
type DivisionError struct {
    dividend float64
    divisor  float64
}

func (e *DivisionError) Error() string {
    return fmt.Sprintf("ไม่สามารถหาร %.2f ด้วย %.2f ได้", e.dividend, e.divisor)
}

// การใช้งาน
func safeDivide(a, b float64) (float64, error) {
    if b == 0 {
        return 0, &DivisionError{a, b}
    }
    return a / b, nil
}

func main() {
    result, err := safeDivide(5, 0)
    if err != nil {
        fmt.Println("เกิดข้อผิดพลาด", err)
    } else {
        fmt.Printf("5 / 0 = %.2f\n", result)
    }
}

ในตัวอย่างนี้เราไม่ได้เรียกใช้ Function "errors.New()" ใน Code จึงไม่ต้องมีการ Import Package "errors"

Go มีกลไกพิเศษสำหรับจัดการกับข้อผิดพลาดร้ายแรง

เราจะใช้คำสั่ง defer ที่คล้ายกับ Try-Finally ในภาษาอื่น แต่มีความยืดหยุ่นมากกว่า เพื่อทำให้แน่ใจว่าข้อผิดพลาดร้ายแรงจะถูกจัดการก่อน Program จะหยุดทำงาน

defer ช่วยให้เราสามารถจัดการกับสิ่งที่ต้องทำหลังจากงานหลักเสร็จ โดยไม่ต้องกังวลว่าจะลืม และช่วยให้ Code ของเราเป็นระเบียบมากขึ้น เหมือนกับการวางแผนให้ดีก่อนไปเที่ยว โดยการจดสิ่งที่ต้องทำหลังกลับบ้าน ลงในสมุดโน้ต เราจะได้สนุกเต็มที่โดยไม่ต้องกังวลว่าจะลืมทำอะไรเมื่อกลับถึงบ้าน

func readFile(filename string) error {
    file, err := os.Open(filename)
    if err != nil {
        return err
    }
    defer file.Close()

    // ทำงานกับไฟล์...
    return nil
}

จากตัวอย่างนี้ เมื่อเจอคำสั่ง defer, Go จะบันทึก Function และ Argument (ถ้ามี) ไว้ โดย Function "file.Close()" ที่ถูก defer จะถูกเรียกหลังจาก Function "readFile()" ที่ครอบอยู่ทำงานเสร็จ ก่อนการคืนค่า nil (return nil)

defer ทำให้ Code อ่านง่ายขึ้น โดยวาง Cleanup Code เช่น การปิดไฟล์เพื่อคืนทรัพยากร ไว้ใกล้กับจุดที่ใช้ทรัพยากร

ถ้ามีหลาย defer มันจะทำงานแบบ Last In First Out (LIFO)

func processFile() {
    defer fmt.Println("3. ปิดไฟล์")
    defer fmt.Println("2. เขียนข้อมูลลงไฟล์")
    defer fmt.Println("1. เปิดไฟล์")

    fmt.Println("กำลังประมวลผล...")
}
// Output:
// กำลังประมวลผล...
// 1. เปิดไฟล์
// 2. เขียนข้อมูลลงไฟล์
// 3. ปิดไฟล์

defer ทำงานแม้ในกรณีที่เกิด Panic

กลไกพิเศษสำหรับจัดการกับ Error ร้ายแรงในที่นี้คือ recover() ซึ่งจะส่งค่ากลับที่ไม่ใช่ nil เมื่อเกิด Panic ดังนั้นเมื่อใช้ร่วมกับ defer ก็จะทำให้มั่นใจได้ว่า มันจะจัดการ Error เมื่อเกิด Panic โดยที่ Program ยังคงสามารถทำงานต่อไปได้

package main

import (
    "fmt"
)

// Function ที่อาจเกิดข้อผิดพลาดร้ายแรง
func riskyFunction() {
    // defer Function ที่จะจับ Panic
    defer func() {
        // ใช้ recover() เพื่อจับ Panic
        if r := recover(); r != nil {
            fmt.Println("Recovered:", r)
        }
    }()

    // จำลองการเกิดข้อผิดพลาดร้ายแรงด้วย Panic
    panic("เกิดข้อผิดพลาดร้ายแรง!")

    // บรรทัดนี้จะไม่ถูกทำงานเนื่องจากเกิด Panic ก่อนหน้านี้
    fmt.Println("บรรทัดนี้จะไม่ถูกพิมพ์")
}

func main() {
    // เรียกใช้ Function ที่อาจเกิดข้อผิดพลาด
    riskyFunction()

    // แสดงข้อความว่าโปรแกรมยังทำงานต่อได้
    fmt.Println("โปรแกรมยังทำงานต่อไปได้")
}

Exercise

เขียน Function ที่อาจเกิดข้อผิดพลาด และจัดการกับข้อผิดพลาดที่อาจเกิดขึ้น

Go Quiz 9 (15 ข้อ) ขอให้สนุกกับการทำ Quiz นะครับ

Q&A?

รวม Cheat Sheet Error, Defer, Panic และ Recover

Error, Defer, Panic และ Recover
-------------------------------

1. การสร้างและคืนค่า Error
   errors.New("ข้อความอธิบายข้อผิดพลาด")

2. การตรวจสอบ Error
   if err != nil {
       // จัดการกับข้อผิดพลาด
   }

3. Custom Error
   type MyError struct {
       // ฟิลด์ที่เกี่ยวข้อง
   }
   func (e *MyError) Error() string {
       return "คำอธิบายข้อผิดพลาด"
   }

4. Defer
   defer function()  // ทำงานหลังจาก Function หลักเสร็จสิ้น

5. Panic
   panic("ข้อความอธิบายสถานการณ์ร้ายแรง")

6. Recover
   defer func() {
       if r := recover(); r != nil {
           // จัดการกับ Panic
       }
   }()

7. Best Practice
   - ตรวจสอบ error ทุกครั้งที่เรียก Function ที่อาจเกิดข้อผิดพลาด
   - ใช้ Custom Error เพื่อให้ข้อมูลเพิ่มเติมเกี่ยวกับข้อผิดพลาด
   - ใช้ Panic เฉพาะกรณีที่โปรแกรมไม่สามารถดำเนินการต่อได้จริงๆ

การทดสอบเบื้องต้น : ลองชิมอาหารก่อนเสิร์ฟ

การทดสอบเป็นส่วนสำคัญในการพัฒนา Software ที่มีคุณภาพ การทดสอบช่วยให้มั่นใจว่า Code ของเราทำงานถูกต้องตามความต้องการ

Unit Test เป็นการทดสอบส่วนย่อยที่สุดของ Code ซึ่งโดยปกติคือการทดสอบระดับ Function หรือ Method เพื่อตรวจสอบว่ามันทำงานถูกต้องตามที่คาดหวังหรือไม่

การเขียน Unit Test เป็นความรับผิดชอบร่วมกันของทีม โดยมี Software Developer เป็นผู้รับผิดชอบหลัก ซึ่ง Software Developer ควรเขียน Unit Test สำหรับ Code ที่ตนเองพัฒนา

การทำ Unit Test จะช่วยให้การ Refactor Code ง่ายขึ้น ซึ่ง Refactor Code เป็นการปรับปรุง Code เพื่อให้อ่านง่าย บำรุงรักษาง่าย และมีประสิทธิภาพมากขึ้น โดยไม่เปลี่ยนแปลงพฤติกรรมภายนอกที่มองเห็นได้ เพราะเราสามารถ Refactor มันทีละส่วน และรัน Test เพื่อยืนยันความถูกต้องในแต่ละขั้นตอน

Go มี Standard Library "testing" สำหรับทำ Unit Test โดยไม่ต้องติดตั้ง Library เพิ่มเติม

// ไฟล์ internal/math/calculator.go
package math

func Add(a, b int) int {
    return a + b
}

// ไฟล์ internal/math/calculator_test.go
package math

import "testing"

func TestAdd(t *testing.T) {
    result := Add(2, 3)
    if result != 5 {
        t.Errorf("Add(2, 3) = %d; want 5", result)
    }
}

สมมติว่าเรากำลังสร้าง Package "math" ที่มี Function "Add" อยู่ในไฟล์ calculator.go

เพื่อตรวจสอบว่ามันทำงานถูกต้องตามที่คาดหวังหรือไม่ เราจึงเขียน Unit Test เพื่อทดสอบ Code โดยตั้งชื่อไฟล์ที่ลงท้ายด้วย _test.go และเก็บไว้ใน Folder เดียวกันกับ Code ของ Package "math" แม้ว่าไฟล์ Test จะไม่จำเป็นต้องมีชื่อข้างหน้าเหมือนกับไฟล์ที่เก็บ Fucntion ที่จะ Test ใน Package "math" แต่เพื่อให้สามารถแยกการทดสอบตามกลุ่ม Function ในไฟล์ได้อย่างชัดเจน โดยไม่เกิดความสับสนในภายหลัง เราจึงตั้งชื่อไฟล์ Test เป็น calculator_test.go แล้วจึงเขียน Test Case

เมื่อรัน Test และปรับปรุง Package จนผ่านทุก Test Case ตามที่คาดหวังแล้ว เราจะทดลองใช้งาน Package "math" ใน main.go ต่อไป

Project ที่มีการทำ Unit Test สามารถใช้โครงสร้าง ดังตัวอย่างด้านล่าง

myproject/
├── cmd/
│   └── main.go
├── internal/
│   └── math/
│       ├── calculator.go
│       └── calculator_test.go
├── go.mod
└── go.sum
  • Folder cmd สำหรับเก็บไฟล์หลักของ Application
  • Folder internal สำหรับเก็บ Package ที่ใช้ภายใน Project นี้
  • ไฟล์ go.mod และ go.sum ไฟล์สำหรับการจัดการ Dependency

แต่ก่อนอื่นให้เราสร้าง Folder cmd, internal และ math รวมทั้งไฟล์ calculator.go และ calculator_test.go ดังต่อไปนี้

calculator.go คือ ไฟล์ที่เก็บ Code ใน Package "math" ซึ่งมี Function "Add" ที่จะ Test

// ไฟล์ internal/math/calculator.go
package math

func Add(a, b int) int {
	return a + b
}

calculator_test.go คือไฟล์ Test ซึ่งต้องอยู่ใน Package เดียวกันกับ Code ที่จะทดสอบ

Function Test ของ Go ต้องขึ้นต้นด้วยคำว่า "Test" และอาจตามด้วยชื่อ Function หรือ Feature ที่ต้องการจะ Test โดยต้องมีการรับพารามิเตอร์เป็น Pointer ไปยัง Struct T (*testing.T)

หมายเหตุ testing คือ ชื่อ Package และ T คือ Struct ใน Package "testing" ที่มี Method สำหรับการ Test เช่น Method Errorf() สำหรับรายงานความล้มเหลวและรัน Test Case ที่เหลือต่อ

// ไฟล์ internal/math/calculator_test.go
package math

import "testing"

func TestAdd(t *testing.T) {
	result := Add(2, 3)
	//การ Assert
	if result != 5 {
		t.Errorf("Add(2, 3) = %d; want 5", result)
	}
}

โครงสร้าง Project สำหรับการ Test ในตอนนี้

myproject/
├── cmd
└── internal
    └── math
        ├── calculator.go
        └── calculator_test.go

ก่อนรัน Test เราควรจะต้องมีไฟล์ go.mod โดยการใช้คำสั่ง go mod init

go mod init myproject

ตัวอย่างไฟล์ go.mod

module myproject

go 1.21.0
go.mod

โครงสร้าง Project สำหรับการ Test ในตอนนี้

.
├── cmd
├── go.mod
└── internal
    └── math
        ├── calculator.go
        └── calculator_test.go

เพื่อจะ Test เราจะใช้คำสั่ง go test ./... เพื่อ Test ทั้ง Project หรือใช้คำสั่ง  go test ./internal/math เพื่อระบุ Package ที่จะ Test โดยตรง

go test ./...
หรือ
go test ./internal/math 

เพื่อให้ง่ายต่อการเพิ่มหรือแก้ไข Test Case และสามารถทดสอบได้หลายกรณีพร้อมกันใน Function เดียว เราจะใช้เทคนิค Table-Driven Test ในการเขียน Test Case

Table-Driven Test คือ การเขียนชุดทดสอบโดยใช้โครงสร้างข้อมูลแบบตาราง (เช่น slice ของ struct) เพื่อกำหนดชุดของ Input และ Expected Output

เราจะสร้าง slice ของ struct ที่มี name ซึ่งอธิบายว่ากำลังทดสอบอะไร, input (a และ b) และ expected (ค่าที่คาดหวัง) แล้วใช้ t.Run() เพื่อรัน Subtest สำหรับแต่ละ Test Case

โดยการใช้ t.Run() จะทำให้ Go สามารถแสดงผลการทดสอบแยกตาม Subtest และเห็นว่า Test Case ไหนผ่านหรือไม่ผ่าน

ในแต่ละ Subtest เราเรียก Function "Add" ด้วย Input ที่กำหนด และเปรียบเทียบผลลัพธ์กับค่าที่คาดหวัง ถ้าผลลัพธ์ไม่ตรงกับค่าที่คาดหวัง เราจะใช้ t.Errorf() เพื่อรายงานความล้มเหลวพร้อมกับข้อมูลที่เกี่ยวข้อง

// ไฟล์ internal/math/calculator.go

package math

import "testing"

func TestAdd(t *testing.T) {
    // กำหนดชุดของ test cases
    tests := []struct {
        name     string
        a        int
        b        int
        expected int
    }{
        {"positive numbers", 2, 3, 5},
        {"negative numbers", -2, -3, -5},
        {"mixed numbers", -2, 3, 1},
        {"zero and positive", 0, 5, 5},
        {"zero and negative", 0, -5, -5},
        {"both zero", 0, 0, 0},
        {"large numbers", 1000000, 2000000, 3000000},
    }

    // วนลูปทดสอบแต่ละ test case
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            result := Add(tt.a, tt.b)
            if result != tt.expected {
                t.Errorf("Add(%d, %d) = %d; want %d", tt.a, tt.b, result, tt.expected)
            }
        })
    }
}

ในการ Test เราสามารถใช้คำสั่ง go test -v เพื่อให้ Go แสดงรายละเอียดที่มากขึ้น

go test -v ./...
หรือ
go test -v ./internal/math 

Test Coverage คือการวัดว่า Code ของเราถูกทดสอบมากน้อยเพียงใด โดย Go จะวัด % ของ Coverage จากสัดส่วนของจำนวน Statement ใน Code ที่ถูกรันระหว่างการทดสอบกับจำนวน Statement ทั้งหมด

ในการดู % ของ Coverage เราสามารถใช้คำสั่ง go test -cover

go test -cover ./...

อย่างไรก็ตาม Coverage 100% ไม่ได้หมายความว่าทุก Logic ใน Code จะถูกทดสอบ เราจึงควรพิจารณาคุณภาพของ Test Cast ควบคู่ไปกับ % ของ Coverage ด้วย

หลังจากรัน Unit Test แล้วเราจะทดลองใช้งาน Package "math" ใน main.go ดังตัวอย่างต่อไปนี้

// ไฟล์ cmd/main.go

package main

import (
	"fmt"
	"myproject/internal/math"
)

func main() {
	result := math.Add(5, 3)
	fmt.Printf("ผลลัพธ์ของ 5 + 3 = %d\n", result)

	// ทดลองใช้งานเพิ่มเติม
	a := 10
	b := 7
	sum := math.Add(a, b)
	fmt.Printf("ผลลัพธ์ของ %d + %d = %d\n", a, b, sum)
}

โครงสร้าง Project สำหรับการ Test จะมีดังนี้

myproject/
├── cmd
│   └── main.go
├── go.mod
└── internal
    └── math
        ├── calculator.go
        └── calculator_test.go

เราสามารถรัน Code จาก main.go ที่ myproject ด้วยคำสั่ง go run cmd/main.go ครับ

go run cmd/main.go

Unit Test กับ Grader

นักศึกษาหลายคนมักจะเคยชินกับการฝึกทำโจทย์ด้วย Grader โดยการส่ง Code ไปตรวจที่ Server ซึ่งมีการกำหนด Test Case ไว้ล่วงหน้า โดยมากมักจะเป็น Test Case แบบ Positive Test (Happy Path) ที่จะมีการตรวจสอบว่าผลลัพธ์ตรงตามที่คาดหวังในกรณีปกติหรือไม่

Grader ทำให้ผู้เรียนสามารถทำโจทย์ได้มากขึ้นในเวลาที่จำกัด ซึ่งง่ายกว่าการต้องมานั่งเขียน Unit Test สำหรับผู้ที่เพิ่งเริ่มเรียน Programming

รวมทั้งการใช้ Test Case เดียวกันจะทำให้การประเมินผลมีมาตรฐานเดียวกัน

นอกจากนี้ Grader ยังเหมาะสำหรับการฝึกไปแข่ง ที่เน้นการแก้ปัญหา Algorithm ที่ซับซ้อนในเวลาที่จำกัด โดยการฝึกโจทย์แข่งขันมาก ๆ และศึกษา Advance Data Structure และ Advance Algorithm ไปด้วย

อย่างไรก็ตามสำหรับผู้ที่ต้องการจะเป็น Software Developer ที่ดี การฝึกด้วย Grader อาจจะไม่เพียงพอ เพราะ Grader ให้ผลลัพธ์แบบผ่าน/ไม่ผ่าน โดยไม่ให้รายละเอียดอื่นมากนัก การไม่เปิดเผย Test Case ทำให้ไม่เห็นภาพรวมของการทดสอบ อาจทำให้ผู้เรียนไม่เข้าใจสาเหตุที่แท้จริงของข้อผิดพลาด และเกิดการแก้ปัญหาแบบลองผิดลองถูก

ดังนั้นเพื่อจะพัฒนาทักษะของ Software Developer เราจึงควรฝึกทำ Project จริงให้มากขึ้น เน้นการทำงานเป็นทีม และฝึกการเขียน Code ที่มีคุณภาพ

การฝึกเขียน Unit Test จะทำให้เราเห็นความสัมพันธ์ระหว่าง Input และ Output ที่คาดหวัง และเห็นว่า Program ผิดพลาดตรงไหน ซึ่งครอบคลุมทั้ง Positive Test (Happy Path) และ Negative Test (Error Case) ที่จะตรวจสอบว่า Function สามารถจัดการกับข้อมูลที่ไม่ถูกต้องได้อย่างเหมาะสมหรือไม่

เราสามารถออกแบบ Test Case เพื่อทดสอบเฉพาะส่วนได้เอง ซึ่งเป็นการฝึกการมองปัญหาจากหลายมุมมองด้วยตนเอง ไม่ใช่ตามมุมมองของคนเขียน Test Case เช่นอาจารย์เท่านั้น รวมทั้งจะทำให้เราเข้าใจ Edge Case หรือสถานการณ์ที่ไม่ปกติหรือเกิดขึ้นไม่บ่อย และข้อจำกัดของ Program เช่น การป้อนค่าว่างหรือค่า nil การทดสอบกับค่าสูงสุดหรือต่ำสุดที่เป็นไปได้ และการจัดการกับรูปแบบข้อมูลที่ไม่ถูกต้องหรือไม่คาดคิด จึงทำให้เราเข้าใจโจทย์จริงได้ลึกซึ้ง ซึ่งเป็นรากฐานสำคัญในการพัฒนาทักษะการคิดวิเคราะห์สำหรับ Software Developer ครับ

Exercise

เขียน Test Function สำหรับฟังก์ชัน Multiply

Go Quiz 10 (15 ข้อ) ขอให้สนุกกับการทำ Quiz นะครับ

Q&A?

รวม Cheat Sheet การทดสอบเบื้องต้น : ลองชิมอาหารก่อนเสิร์ฟ

การทดสอบเบื้องต้น
--------------

1. สร้างไฟล์ทดสอบ
   - ชื่อไฟล์ต้องลงท้ายด้วย _test.go
   - อยู่ใน Package เดียวกับ Code ที่ต้องการทดสอบ

2. เขียน Function ทดสอบ
   func TestXxx(t *testing.T) {
       // Code ทดสอบ
   }

3. การ Assert
   if got != want {
       t.Errorf("ได้ %v, ต้องการ %v", got, want)
   }

4. รัน Tests
   go test
   go test -v  // แสดงรายละเอียดมากขึ้น

5. Table-Driven Tests
   tests := []struct {
       input    type
       expected type
   }{
       {input1, expected1},
       {input2, expected2},
   }
   for _, tt := range tests {
       // ทดสอบแต่ละกรณี
   }

6. Subtests
   t.Run("ชื่อ subtest", func(t *testing.T) {
       // โค้ดทดสอบ
   })

7. Test Coverage
   go test -cover

พบกับ Mastering Golang for E-commerce Back End Development : Part 3 เร็ว ๆ  นี้ครับ