ฝึกใช้ generics ใน Go
Tags: go
Published: 2022-03-24 15:48:04

เรื่องราวที่จะเขียนต่อไปนี้มาจากต้นฉบับจาก https://go.dev/doc/tutorial/generics

ว่าด้วยเรื่องราวเกี่ยวกับพื้นฐานความรู้ในเรื่อง generics ใน Go โดยสามารถเขียนไว้กับฟังก์ชันก็ได้ หรือ เขียนไว้กับ type ใดๆก็ได้ โดยเวลาที่จะมาเรียกใช้ มันจะสามารถทำงานเข้ากับ type อะไรก็ได้ที่เราอยากจะส่งเข้าไป

ในบทเรียนนี้เราจะให้คุณสร้างฟังก์ชันขึ้นมาก 2 ตัว ที่ทำหน้าที่เหมือนกันเป๊ะ แต่ทำกับ type ที่ต่างกัน หลังจากนั้นเราค่อยใช้ generic เพิ่มรวมสองฟังก์ชันนั้นเข้ามาเหลือฟังก์ชันเดียว

เราจะทำตามขั้นตอนดังนี้:

1. สร้าง folder สำหรับเขียนโค้ดของเรา
2. เพิ่มฟังก์ชัน แบบไม่ใช้ generic
3. เพิ่มฟังก์ชัน แบบใช้ generic เพื่อให้รับได้หลากหลาย type
4. ทดลองเรียก generic function โดยเอา type argument ออกไป
5. ลองสร้าง type constraint ด้วยตัวเอง

สิ่งที่ต้องเตรียม

- ติดตั้ง Go 1.18 หรือใหม่กว่า
- หา editor ที่ชอบที่ชอบมาสักตัว
- command terminal จะช่วยให้เราดูเก่งขึ้น 555 เปล่าๆ ที่จริง Go ทำงานได้ดีบน terminal ต่างหาก

## สร้าง folder สำหรับเขียนโค้ดของเรา

1. เริ่มด้วยการเปิด command prompt ขึ้นมาแล้วไปที่ home directory

  On Linux or Mac:

```sh
$ cd
```

  On Windows:

```sh
C:\> cd %HOMEPATH%
```

2. สร้าง directory บน command prompt

```sh
$ mkdir generics
$ cd generics
```

3. สร้างโมดูล

```sh
$ go mod init example/generics
go: creating new go.mod: module example/generics
```

## เพิ่มฟังก์ชัน แบบไม่ใช้ generic

ขั้นตอนนี้เราจะเพิ่มสองฟังก์ชัน โดยแต่ละตัวจะรับ map เข้ามาแล้วทำการหาผลบวกของ ค่าในแต่ละคีย์ แล้วส่งผลลัพธ์ออกไป

เราจะต้องสร้างสองฟังก์ชัน แทนที่จะสร้างเพียงแค่ฟังก์ชันเดียว ก็เพราะว่ามันต้องทำงานกับ type คนละแบบกันนั่นเอง โดยตัวนึงคือ map ที่เก็บ int64 ส่วนอีกตัวคือ float64

### เขียนโค้ด

1. ใช้ text editor ที่ชอบที่ชอบ สร้างไฟล์ขึ้นมา ตั้งชื่อว่า `main.go` ภายในไดเร็กทอรี generics แล้วเดี๋ยวเราจะมาเขียนโค้ดให้มันอีกที
2. ใน main.go ที่บรรทัดบนสุดให้ประกาศ package ตามนี้

```go
package main
```

3. ใต้การประกาศ package ให้วางโค้ดนี้ลงไป

```go
// SumInts adds together the values of m.
func SumInts(m map[string]int64) int64 {
    var s int64
    for _, v := range m {
        s += v
    }
    return s
}

// SumFloats adds together the values of m.
func SumFloats(m map[string]float64) float64 {
    var s float64
    for _, v := range m {
        s += v
    }
    return s
}
```

ในโค้ดนี้คุณได้ทำการ:

  - ประกาศฟังก์ชันสองตัว โดยทั้งคู่ทำหน้าที่เดียวกันคือ รวมค่าใน map ออกมาเป็นผลลัพธ์
    - SumFloats รวมค่าของ float64
    - SumInts รวมค่าของ int64

4. กลับไปที่บรรทัดต่อจากการประกาศ package อีกครั้ง แล้ววางโค้ดนี้ลองไป เพื่อเรียกใช้ฟังก์ชันที่เราเพิ่งสร้างไปเมื่อครู่

```go
func main() {
    // Initialize a map for the integer values
    ints := map[string]int64{
        "first":  34,
        "second": 12,
    }

    // Initialize a map for the float values
    floats := map[string]float64{
        "first":  35.98,
        "second": 26.99,
    }

    fmt.Printf("Non-Generic Sums: %v and %v\n",
        SumInts(ints),
        SumFloats(floats))
}
```

ในโค้ดนี้คุณได้ทำการ:
  - สร้างและกำหนดค่าให้กับ map ของ float64 และ map ของ int64
  - เรียกฟังก์ชันทั้งสองตัวที่สร้างไว้ก่อนหน้านี้เพื่อหาผลรวมของค่าในแต่ละ map
  - พิมพ์ผลลัพธ์ออกทางหน้าจอ

5. กลับไปที่แถวๆบนสุดของ main.go อีกสักรอบ และเอา import ไปใส่ให้เหมือนตัวอย่างข้างล่างนี้

```go
package main

import "fmt"
```

6. Save ไฟล์ main.go

### Run the code

กลับไปที่ command line ในไดเร็กทอรี่ที่มี main.go อยู่แล้วรันโค้ด

```sh
$ go run .

Non-Generic Sums: 46 and 62.97
```

เขียนโค้ดที่ทำงานแบบเดียวกันสองฟังก์ชันแบบนี้ ถ้าเอา generic มาช่วย จะทำให้เราเขียนเพียงฟังก์ชันเดียวก็ทำงานได้แล้ว

## เพิ่มฟังก์ชัน แบบใช้ generic เพื่อให้รับได้หลากหลาย type

ส่วนต่อไปนี้เราจะมาเพิ่มอีกฟังก์ชันที่เรียกว่า generic function ที่สามารถรับ map ที่ไม่ว่าจะเป็น map ที่มี float หรือ int ก็ทำงานแทนสองฟังก์ชันก่อนหน้านี้ได้เหมือนกัน

และเพื่อให้สิ่งนั้นเกิดขึ้นได้ เจ้าฟังก์ชันเดียวของเราจะต้องหาวิธีประกาศ type ที่มันจะสามารถทำงานด้วยได้ก่อน และในทางกลับกัน ตอนที่เรียกใช้งานฟังก์ชันนี้ ก็จะต้องหาทางบอกมันให้รู้ว่ามันจะต้องทำงานกับ map ของ integer หรือ float กันแน่

สิ่งนั้นก็คือการเขียนฟังก์ชันที่ต้องประกาศ type parameter หรือการใส่ type ลงไปเป็นพารามิเตอร์ เพิ่มเข้าไป นี่จะทำให้ฟังก์ชันกลายเป็น generic และทำให้มันทำงานกับ type ที่ต่างกันได้ และตอนเรียกใช้งานมัน เราก็แค่ส่ง type เข้าไปเป็นอากิวเมนต์ ที่ไม่เกี่ยวกับอากิวเมนต์ที่เป็นค่าแบบเดิมของฟังก์ชันนะ

โดย type parameter แต่ละตัวจะถูกกำหนดด้วยสิ่งที่เรียกว่า type constraint ซึ่งเป็นตัวแทนของประเภทของ type ต้นกำเนิดของ type parameter อีกที (เฮือกกก!!!) ถามว่ามันเอาไว้ทำไม ก็เอาไว้บอกคนที่มาเรียกใช้ฟังก์ชันนี้ ว่าจะสามารถใส่ type อะไรเข้ามาได้บ้างนั่นเอง

ถึงแม้ว่าตอนประกาศ constraint มันจะประกอบไปด้วยหลาย type แต่เมื่อตอนที่โค้ดถูกคอมไพล์ มันจะเห็นแค่ type เดียว ก็คือ type ของคนที่มาเรียกมัน ซึ่งถ้านั่นไม่ใช่ type ที่ได้รับอนุญาต มันก็คอมไพล์ไม่ได้อยู่ดี

**ให้ระลึกอยู่เสมอว่า** ตอนที่เขียนขั้นตอนการทำงานกับ generic การใช้ operaions ใดๆในนั้นจะต้องสอดคล้องกับ type parameter ด้วย ตัวอย่างเช่น ถ้าโค้ดในฟังก์ชันนั้น พยายามที่จะทำอะไรบางอย่างกับ string แต่ใน type parameter มี type ประเภทตัวเลขรวมอยู่ด้วย โค้ดนั้นจะคอมไพล์ไม่ผ่านนะ

เอาละ เดี๋ยวโค้ดที่คุณกำลังจะได้เขียนต่อไปนี้มันจะมี constraint ที่ยอมให้ integer กับ float ใช้งานได้

### เขียนโค้ด

1. ต่อจากสองฟังก์ชันก่อนหน้านี้ ให้เราเพิ่ม generic function ลงไปตามนี้

```go
// SumIntsOrFloats sums the values of map m. It supports both int64 and float64
// as types for map values.
func SumIntsOrFloats[K comparable, V int64 | float64](m map[K]V) V {
    var s V
    for _, v := range m {
        s += v
    }
    return s
}
```

ในโค้ดนี้คุณได้ทำการ:
  - ประกาศฟังก์ชัน SumIntsOrFloats ที่รับ type parameter สองแบบ (ในวงเล็บก้ามปู) คือ K และ V และส่วนของพารามิเตอร์ปกติคือตัวแปร m ก็นำ K ไปใช้เป็น type ของคีย์ใน map และใช้ V เป็น type ของ value และให้ฟังก์ชันคืนค่าที่มี type เป็น V ด้วย
  - K เป็น type parameter แบบ comparable โดยค่านี้ถูกประกาศไว้ใน Go เอง เพื่อบอกให้รู้ว่านี่คือ type ใดๆที่สามารถนำมาทำงานกับตัวดำเนินการ == และ != ได้นั่นเอง และเนื่องจาก Go ขอว่าการประกาศ map นั้น จำเป็นจะต้องให้คีย์เป็น comparable เท่านั้น นั่นทำให้เราจำเป็นต้องประกาศ K เป็น comparable
  - กำหนด type ของ V เป็น constraint ที่มีสอง type คือ int64 กับ float64 โดยใช้ | เป็นตัวเชื่อมทั้งสอง type เข้าด้วยกัน หมายความว่า ไม่ว่า type ไหนในสองตัวนี้ก็สามารถเข้ามาทำงานได้

2. ใน main.go ในส่วนของ main ให้เพิ่มโค้ดนี้เพื่อเรียกใช้ฟังก์ชันใหม่

```go
fmt.Printf("Generic Sums: %v and %v\n",
    SumIntsOrFloats[string, int64](ints),
    SumIntsOrFloats[string, float64](floats))
```

ในโค้ดนี้คุณได้ทำการ:
  - เรียกใช้ generic function ที่เพิ่งสร้างไปเมื่อครู่ โดยใช้ map ที่สร้างไว้ก่อนหน้านี้ใส่ลงไปได้เลย
  - ระบุ type argument ในวงเล็บก้ามปู เพื่อบอกให้ชัดไปเลยว่าให้นำ type นี้ลงไปแทน type parameter ที่ถูกเรียก
    ซึ่งเดี๋ยวเราจะมาดูในส่วนต่อจากนี้ว่า เราสามารถละเรื่องนี้ได้เหมือนกัน และปล่อยให้ตัวฟัง์ชันมันเดาเอาเอง
  - พิมพ์ผลลัพธ์ที่ได้ออกมา

### Run the code

กลับไปที่ command line ในไดเร็กทอรี่ที่มี main.go อยู่แล้วรันโค้ด

```sh
$ go run .

Non-Generic Sums: 46 and 62.97
Generic Sums: 46 and 62.97
```

ตอนที่รันโค้ด คอมไพเลอร์จะนำเอา type ที่เรากำหนดลงไปแทนที่ type parameter ในแต่ละการเรียกใช้งานโดยตรง

นี่คือตัวอย่างการเรียกใช้ generic แบบช่วยบอกมันตรงๆ แต่เดี๋ยวเราจะมาดูว่าเราไม่ต้องบอกมันแบบนี้ก็ได้เช่นกัน เพราะตัวคอมไพเลอร์มันฉลาดพอที่จะเดาได้เอง

## ทดลองเรียก generic function โดยเอา type argument ออกไป

ตอนนี้เราจะแก้ไขการเรียกใช้ generic function สักเล็กน้อยด้วยการลบ type argument ออกดู

คุณสามารถละเว้น type argument ตอนที่เรียกใช้ฟังก์ชันได้ เพราะว่า คอมไพเลอร์ของ Go สามารถเดา type ที่เราอยากจะใช้ได้เอง โดยมันเดาจากค่าที่เราใส่ลงไปในฟังก์ชันนั่นแหล่ะ

แต่แอบบอกก่อนนะว่า มันก็ไม่ได้ว่าจะทำได้ทุกครั้งเสมอไป ตัวอย่างเช่น ถ้าเราเรียก generic function โดยไม่ได้ใสอากิวเม้นต์ แบบนี้ก็ยังจะต้องบอก type argument อยู่ดี

### เขียนโค้ด

- ใน main.go ตอนที่เรียกใช้ generic function แก้เป็นแบบนี้แทน

```go
fmt.Printf("Generic Sums, type parameters inferred: %v and %v\n",
    SumIntsOrFloats(ints),
    SumIntsOrFloats(floats))
```

### Run the code

กลับไปที่ command line ในไดเร็กทอรี่ที่มี main.go อยู่แล้วรันโค้ด

```sh
$ go run .

Non-Generic Sums: 46 and 62.97
Generic Sums: 46 and 62.97
Generic Sums, type parameters inferred: 46 and 62.97
```

อีกสักครู่เราทำให้มันดูง่ายขึ้นอีกด้วยการทำให้ฟังก์ชันรับ type constraint เข้ามาแทน

## ลองสร้าง type constraint ด้วยตัวเอง

ตอนสุดท้ายนี้เราจะย้าย constraint ที่คุณสร้างไว้แบบง่ายๆไปเป็น interface ของตัวเอง และเอาไป reuse ได้ ซึ่งจะช่วยให้โค้ดมีความคล่องตัวในการเขียนมากขึ้นหากว่า constraint เริ่มมีความซับซ้อน

คุณสามารถประกาศ constraint เป็น interface ได้ และยังยอมให้เพิ่ม interface ใส่เข้าไปได้อีก ตัวอย่างเช่น ถ้าคุณต้องการประกาศ type constraint ให้มี 3 เมธอด แล้วเอาไปใช้เป็น type parameter ใน generic function เวลาใส่ type argument ลงไป ก็ต้องมี 3 เมธอดนั้นด้วยนะ

Constraint interface สามารถระบุ type ลงไปตรงๆได้ซึ่งเราจะได้เห็นในอีกสักครู่

### เขียนโค้ด

1. ไปประกาศสิ่งนี้ไว้เหนือฟังก์ชัน main โดยวางโค้ดนี้ลงไป

```go
type Number interface {
    int64 | float64
}
```

ในโค้ดนี้คุณได้ทำการ:
  - ประกาศ interface ชื่อ Number เพื่อเอาไปใช้เป็น type constraint
  - ประกาศการรวม int64 และ float64 เข้าด้วยกันใน interface

    ที่จริงมันคือการย้ายการประกาศตรงๆที่ฟังก์ชัน ออกมาเป็น type ใหม่ ซึ่งจะทำให้คุณสามารถใช้ Number แทนการเขียน int64 | float64

2. วางฟังก์ชันนี้ลงไป

```go
// SumNumbers sums the values of map m. It supports both integers
// and floats as map values.
func SumNumbers[K comparable, V Number](m map[K]V) V {
    var s V
    for _, v := range m {
        s += v
    }
    return s
}
```

ในโค้ดนี้คุณได้ทำการ:
  - สร้างฟังก์ชันแบบเดิมเป๊ะ เพียงแค่เปลี่ยนการประกาศ type constraint ไปใช้ interface แทน

3. ใน main ให้วางบรรทัดนี้ลงไปเพิ่ม

```go
fmt.Printf("Generic Sums with Constraint: %v and %v\n",
    SumNumbers(ints),
    SumNumbers(floats))
```

ในโค้ดนี้คุณได้ทำการ:
  - เรียกใช้ SumNumbers

### Run the code

กลับไปที่ command line ในไดเร็กทอรี่ที่มี main.go อยู่แล้วรันโค้ด

```sh
$ go run .

Non-Generic Sums: 46 and 62.97
Generic Sums: 46 and 62.97
Generic Sums, type parameters inferred: 46 and 62.97
Generic Sums with Constraint: 46 and 62.97
```

## สรุป

ที่จริงคุณสามารถไปอ่านของจริงได้ที่ https://go.dev/doc/tutorial/generics
เอาจริงๆผมแปลมันดื้อๆเลยนั่นแหล่ะ เพราะคิดเองไม่ออก 555

โค้ดตัวเต็มหน้าตาเป็นแบบนี้ หรือไปกดเล่นใน play [ได้ที่นี่](https://go.dev/play/p/apNmfVwogK0?v=gotip)

```go
package main

import "fmt"

type Number interface {
    int64 | float64
}

func main() {
    // Initialize a map for the integer values
    ints := map[string]int64{
        "first": 34,
        "second": 12,
    }

    // Initialize a map for the float values
    floats := map[string]float64{
        "first": 35.98,
        "second": 26.99,
    }

    fmt.Printf("Non-Generic Sums: %v and %v\n",
        SumInts(ints),
        SumFloats(floats))

    fmt.Printf("Generic Sums: %v and %v\n",
        SumIntsOrFloats[string, int64](ints),
        SumIntsOrFloats[string, float64](floats))

    fmt.Printf("Generic Sums, type parameters inferred: %v and %v\n",
        SumIntsOrFloats(ints),
        SumIntsOrFloats(floats))

    fmt.Printf("Generic Sums with Constraint: %v and %v\n",
        SumNumbers(ints),
        SumNumbers(floats))
}

// SumInts adds together the values of m.
func SumInts(m map[string]int64) int64 {
    var s int64
    for _, v := range m {
        s += v
    }
    return s
}

// SumFloats adds together the values of m.
func SumFloats(m map[string]float64) float64 {
    var s float64
    for _, v := range m {
        s += v
    }
    return s
}

// SumIntsOrFloats sums the values of map m. It supports both floats and integers
// as map values.
func SumIntsOrFloats[K comparable, V int64 | float64](m map[K]V) V {
    var s V
    for _, v := range m {
        s += v
    }
    return s
}

// SumNumbers sums the values of map m. Its supports both integers
// and floats as map values.
func SumNumbers[K comparable, V Number](m map[K]V) V {
    var s V
    for _, v := range m {
        s += v
    }
    return s
}
```