Lo ไลบรารี่สไตล์ Lodash สำหรับภาษา Go และการสร้าง Utility Functions ด้วยตนเองผ่าน Generics

Nuttavut Thongjor

Developer ฝั่ง JavaScript มีไลบรารี่ช่วยชีวิตชื่อ Lodash ที่ช่วยย่นโค้ดยาวเฟื้อยให้เหลือสั้นลงด้วยพลานุภาพแห่งฟังก์ชันใน Lodash

สำหรับภาษา Go นั้นเราก็มีไลบรารี่ทำนองเดียวกันอยู่บ้าง เช่น Go Funk ทว่าไลบรารี่ดังกล่าวในขณะที่เขียนบทความนี้ยังไม่สนับสนุนการใช้งานกับ Generics อันเป็นฟีเจอร์ใหม่ใน Go 1.18

เราจะมารู้จักกับไลบรารี่ Lo อีกหนึ่งไลบรารี่ที่มีฟังก์ชันช่วยชีวิตคล้ายคลึงกับ Lodash แถมยังสนับสนุนการทำงานกับ Lodash อีกด้วย นอกเหนือจากจะนำเสนอวิธีการใช้งานฟังก์ชันของ Lo แล้ว เราจะลองใช้ภาษา Go เพื่อสร้างการทำงานที่ใกล้เคียงกับการทำงานของฟังก์ชันใน Lo กันด้วย

บทความนี้ผู้อ่านจำเป็นต้องทราบการใช้งาน Generics ใน Go ก่อน หากต้องการข้อมูลเพิ่มเติมสามารถอ่านได้จาก Go Generics คืออะไร? เรียนรู้การใช้งาน Generics ผ่าน Type Parameters

การติดตั้ง Lo

ใช้คำสั่งต่อไปนี้เพื่อติดตั้ง Lo ผ่าน terminal

Code
1go get github.com/samber/lo

Lo นั้นประกอบด้วยฟังก์ชันมากมายให้เรียกใช้งาน สามารถดูรายการการใช้งานได้จาก ที่นี่ สำหรับบทความนี้เราจะนำเสนอเฉพาะบางฟังก์ชันเท่านั้นครับ

การเรียกใช้ฟังก์ชันจาก lo นั้น เริ่มต้นด้วยการ import แพคเกจนี้เข้ามาก่อนในชื่อของ lo

Go
1import "github.com/samber/lo"

เมื่อใช้งานฟังก์ชันใด ๆ จาก lo จึงนำด้วยชื่อแพคเกจคือ lo ขึ้นก่อน เช่นเมื่อต้องการใช้ฟังก์ชัน Uniq จึงมีรูปแบบการใช้งานดังนี้

Go
1import "github.com/samber/lo"
2
3names := lo.Uniq([]string{"Somchai", "Somsree", "Somset", "Somchai"})

ฟังก์ชันของ lo สามารถแบ่งหมวดหมู่ได้หลายประเภท แต่ละกลุ่มฟังก์ชันจะจำเพาะกับการใช้งานผ่านชนิดข้อมูลที่แตกต่างกัน เช่น Map และ Uniq เป็นกลุ่มฟังก์ชันที่ใช้กับ Slice เป็นต้น

กลุ่มฟังก์ชันสำหรับใช้งานกับ Slice

ฟังก์ชันกลุ่มนี้ เช่น Filter, Map, FlatMap, Reduce, Times, Uniq เป็นต้น

การสร้างและใช้งาน Uniq

Uniq เป็นฟังก์ชันสำหรับกำจัดค่าซ้ำใน slice ด้วยการคืน slice ใหม่ที่ไม่มีอีลีเมนต์ใดซ้ำกันเลย

Go
1// ผลลัพธ์: [Somchai Somsree Somset]
2names := lo.Uniq([]string{"Somchai", "Somsree", "Somset", "Somchai"})

การพิจารณาว่าซ้ำหรือไม่ซ้ำนั้นนั่นคือการเปรียบเทียบอีลีเมนต์ในแต่ละช่อง แสดงว่า Slice นั้นสามารถกำหนดแต่ละอีลีเมนต์ให้เป็น comparable ได้ เมื่อทำการสร้าง Uniq ด้วยตนเองจึงได้โค้ดดังนี้

Go
1func Uniq[T comparable](items []T) []T {
2 result := make([]T, 0, len(items))
3 occurrence := make(map[T]struct{}, len(items))
4
5 for _, item := range items {
6 if _, seen := occurrence[item]; !seen {
7 result = append(result, item)
8 occurrence[item] = struct{}{}
9 }
10 }
11
12 return result
13}

การสร้างและใช้งาน GroupBy

GroupBy เป็นฟังก์ชันสำหรับการจัดหมวดหมู่ให้กับแต่ละอีลีเมนต์ภายใต้ Slice ตามเงื่อนไขที่กำหนด ผลลัพธ์ที่ได้จะเป็นชนิดข้อมูล map ที่มีค่าของ key เป็นค่าการจัดหมวดหมู่นั้น ๆ GroupBy นั้นจะมีการส่งฟังก์ชันที่ทำการคืนค่ากลับเป็นค่า key ของ map ผลลัพธ์ key ดังกล่าวจะใช้เพื่อการจัดกลุ่มให้กับข้อมูล

พิจารณาตัวอย่างต่อไปนี้ เราต้องการแปลง Slice ของ []string{"Mr. Somchai", "Mrs. Somsree", "Mr. Somset"} ให้เป็นผลลัพธ์คือ

Go
1map[string]string{
2 "Mr": []string{"Mr. Somchai", "Mr. Somset"},
3 "Mrs": []string{"Mrs. Somsree"}
4}

นั่นแสดงว่าเราใช้คำนำหน้าชื่อเป็นตัวแบ่งกลุ่ม ฟังก์ชันสำหรับการจัดกลุ่มจึงต้องคืนค่า Mr หรือ Mrs เพื่อเป็นค่า key

รูปแบบของการเรียกใช้งาน GroupBy เป็นดังนี้

Go
1names := []string{"Mr. Somchai", "Mrs. Somsree", "Mr. Somset"}
2groups := lo.GroupBy(names, func(name string) string {
3 return strings.Split(name, ". ")[0]
4})

ต่อไปนี้เป็นการสร้าง GroupBy ด้วยตัวของเราเอง เนื่องจาก Slice ที่เป็นค่าเริ่มต้นจะประกอบด้วยอีลีเมนต์ที่มีชนิดข้อมูลเป็นอะไรก็ได้ เราจึงกำหนดให้ค่า input มีชนิดข้อมูลเป็น []T เมื่อ T กำหนด constraint เป็น any ส่วนค่า key นั้นเราต้องทำการเปรียบเทียบเพื่อจัดกลุ่มอีลีเมนต์ตามค่า key เราจึงกำหนด key ให้มีชนิดข้อมูลเป็น K เมื่อ K มี constraint เป็น comparable

Go
1func GroupBy[T any, K comparable](items []T, fn func(item T) K) map[K][]T {
2 result := map[K][]T{}
3
4 for _, item := range items {
5 key := fn(item)
6 result[key] = append(result[key], item)
7 }
8
9 return result
10}

การสร้างและใช้งาน RangeFrom

ในสถานการณ์ที่เราต้องการสร้าง Slice ของตัวเลขแบบต่อเนื่อง เช่น []int{5, 6, 7, 8, 9} เราสามารถใช้ฟังก์ชัน RangeFrom ด้วยการระบุสองอาร์กิวเมนต์ เมื่อตัวแรกคือค่าเริ่มต้น เช่น 5 ในขณะที่ตัวสุดท้ายเป็นค่าสิ้นสุดบวกด้วย 1 เช่น 10

Go
1// []int{5, 6, 7, 8, 9}
2// ผลลัพธ์ไม่รวม 10
3lo.RangeFrom(5, 10)

ค่าของสิ่งที่จะส่งเป็นอาร์กิวเมนต์ได้ต้องเป็นตัวเลขไม่ว่าจะเป็นตระกูล Interger หรือ Float เราจึงกำหนดให้อาร์กิวเมนต์มีชนิดข้อมูลเป็น T เมื่อ T ถูกกำหนด constraint เป็น constraints.Integer | constraints.Float และนี่คือโค้ดที่เราสร้างได้ด้วยตนเอง

Go
1import "golang.org/x/exp/constraints"
2
3func RangeFrom[T constraints.Integer | constraints.Float](from T, to T) []T {
4 var result []T
5
6 for i := from; i < to; i++ {
7 result = append(result, i)
8 }
9
10 return result
11}

การสร้างและใช้งาน Map

เมื่อเรามี slice ของข้อมูลชุดหนึ่ง บางครั้งเราอาจอยากแปลงข้อมูลแต่ละตัวใน slice เพื่อให้เกิดเป็นข้อมูลใหม่ โดยจำนวนสมาชิกใน slice ก่อนและหลังการดำเนินการต้องเท่ากัน ตัวอย่างเช่นต้องการแปลง []int{1, 2, 3, 4} ให้เกิดเป็น slice ใหม่ที่นำ 2 ไปคูณสมาชิกทุกตัวก่อผลลัพธ์เป็น []int{2, 4, 6, 8} ลักษณะเช่นนี้เราสามารถดำเนินการได้ผ่าน Map

Go
1lo.Map([]int{1, 2, 3, 4}, func(item int, _ int) int {
2 return item * 2
3})

สิ่งที่สำคัญของการเรียก Map คือการส่งอาร์กิวเมนต์สองค่าเมื่อค่าแรกคือ slice และค่าสุดท้ายคือฟังก์ชันเปลี่ยนค่า ฟังก์ชันนี้จะรับพารามิเตอร์สองค่าโดยค่าแรกคือสมาชิกแต่ละตัวของ slice ที่จะถูกวนลูปส่งมาในแต่ละรอบ ค่าที่สองของฟังก์ชันจะเป็นเลขลูปหรือลำดับสมาชิกโดยเริ่มจากศูนย์ จากชุดข้อมูล []int{1, 2, 3, 4} รอบแรกของการเรียกฟังก์ชันจะทำตัวแปร item มีค่าเป็น 1 ในขณะที่ _ มีค่าเป็น 0 สิ่งใดก็ตามที่คืนจากฟังก์ชันสิ่งนั้นจะกลายเป็นผลลัพธ์ของสมาชิก slice ใหม่ เมื่อเราคูณ 2 ในทุกสมาชิกพร้อมคืนค่านี้จากฟังก์ชัน ผลลัพธ์สุดท้ายจึงได้เป็น []int{2, 4, 6, 8}

ต่อไปนี้คือชุดคำสั่งที่เราสร้าง Map ด้วยตนเองผ่าน Generics

Go
1func Map[T, R any](items []T, fn func(T, int) R) []R {
2 result := make([]R, len(items))
3
4 for i, item := range items {
5 result[i] = fn(item, i)
6 }
7
8 return result
9}

การสร้างและใช้งาน Filter

ในบางครั้งเราต้องการกรองเฉพาะข้อมูลที่สนใจใน slice เช่น กรองเอาเฉพาะตัวเลขที่เป็นลูกคู่จาก slice ของตัวเลข กรณีเช่นนี้เราใช้ slice

Go
1// ผลลัพธ์คือ []int{2, 4}
2lo.Filter([]int{1, 2, 3, 4}, func(x int, _ int) bool {
3 return x%2 == 0
4})

Filter จะรับพารามิเตอร์ 2 ค่าเมื่อค่าแรกคือ slice ส่วนค่าที่สองคือฟังก์ชันที่ใช้ตรวจสอบเงื่อนไข หากผลลัพธ์ที่คืนจากฟังก์ชันนี้เป็น true สมาชิกของ slice จะไปปรากฎในผลลัพธ์ ในทางตรงข้ามเมื่อฟังก์ชันคืนค่า false สมาชิกที่วนลูปขณะนั้นจะไม่ปรากฎใน slice ผลลัพธ์

เราสามารถสร้าง Filter ด้วยตนเองผ่าน Generics ได้ ดังนี้

Go
1func Filter[T any](items []T, fn func(item T, _ int) bool) []T {
2 var result []T
3
4 for i, item := range items {
5 if fn(item, i) {
6 result = append(result, item)
7 }
8 }
9
10 return result
11}

กลุ่มฟังก์ชันสำหรับใช้งานกับ Map

ฟังก์ชันในกลุ่มนี้เน้นจัดการกับข้อมูลประเภท map ได้แก่ฟังก์ชัน Keys, Values, Entries และ FromEntries เป็นต้น

การสร้างและใช้งาน Keys

Keys คือฟังก์ชันที่ใช้เพื่อดึงค่าของ keys ทั้งหมดของ Map กลับคืนมาเป็น slice

Go
1// ผลลัพธ์คือ []string{"C++", "Java"}
2lo.Keys(map[string]int{"C++": 3, "Java": 2})

จากตัวอย่างการใช้งานฟังก์ชัน Keys จะทำการรับพารามิเตอร์ที่มีชนิดข้อมูลแบบ Map เหตุเพราะค่า key ของ Map ต้องเป็นชนิดข้อมูลที่เปรียบเทียบค่าได้จึงต้องกำหนด key ด้วย constraint แบบ comparable

Go
1func Keys[K comparable, V any](m map[K]V) []K {
2 keys := make([]K, 0, len(m))
3
4 for k := range m {
5 keys = append(keys, k)
6 }
7
8 return keys
9}

การสร้างและใช้งาน Values

ตรงกันข้ามกับ Keys ที่คืนค่าของ key ทั้งหมดใน Map ออกมา สำหรับ Values นั้นจะคืนค่าของ value ทั้งหมดใน Map เป็น Slice

Go
1// ผลลัพธ์คือ []int{3, 2}
2lo.Values(map[string]int{"C++": 3, "Java": 2})

วิธีการสร้างฟังก์ชัน Values แทบไม่แตกต่างจาก Keys เท่าไร หากแต่เลือกคืนค่า value แทนที่จะเป็นค่า key

Go
1func Values[K comparable, V any](m map[K]V) []V {
2 values := make([]V, 0, len(m))
3
4 for _, v := range m {
5 values = append(values, v)
6 }
7
8 return values
9}

กลุ่มฟังก์ชัน Intersection Helpers

ตัวอย่างของฟังก์ชันในกลุ่มนี้ เช่น Contains และ Every เป็นต้น

การสร้างและใช้งาน Contains

Contains เป็นฟังก์ชันสำหรับการตรวจสอบว่าค่าข้อมูลที่เราสนใจนั้นปรากฎใน Slice ที่เราระบุหรือไม่ หากค้นหาพบฟังก์ชันนี้จะคืน true และคืน false เมื่อการค้นหานั้นไม่พบสิ่งที่ต้องการ

Go
1// 5 ปรากฏใน Slice ผลลัพธ์จึงเป็น true
2Contains([]int{1, 2, 3, 4, 5}, 3)

ฟังก์ชันนี้ต้องอาศัยการเปรียบเทียบค่าที่เราสนใจกับแต่ละอีลีเมนต์ใน Slice เมื่อเป็นเช่นนี้ Type Parameter จึงต้องกำหนด constraint เป็น comparable

Go
1func Contains[T comparable](items []T, element T) bool {
2 for _, item := range items {
3 if item == element {
4 return true
5 }
6 }
7
8 return false
9}

การสร้างและใช้งาน Every

Every เป็นฟังก์ชันสำหรับการตรวจสอบว่า Slice ที่เรากำหนดเป็นส่วนหนึ่งของอีก Slice หรือไม่ ผลลัพธ์จากการทำงานของฟังก์ชันจะเป็น bool

Go
1// เนื่องจากค่า 0 และ 2 ปรากฏทั้งสองค่าใน Slice ของอาร์กิวเมนต์แรก
2// ผลลัพธ์จากการทำงานจึงคืน true
3lo.Every([]int{0, 1, 2, 3, 4, 5}, []int{0, 2})

วิธีการสร้างฟังก์ชันนี้อาศัยการทำงานของ Contains อีกรอบ เพื่อตรวจสอบว่าแต่ละค่าใน subset ต้องปรากฏใน Slice หลักทุกค่า

Go
1func Every[T comparable](items []T, subset []T) bool {
2 for _, item := range subset {
3 if !Contains(items, item) {
4 return false
5 }
6 }
7
8 return true
9}

การสร้างและใช้งาน Some

การทำงานของ Some จะคล้ายกับฟังก์ชัน Every ต่างกันตรงที่ว่าฟังก์ชัน Some นั้นหากใน subset ปรากฏใน Slice หลักเพียงบางค่า การทำงานก็จะคืนผลลัพธ์เป็น true แล้ว

Go
1// แม้ 9 จะไม่เป็นส่วนหนึ่งของมันแต่ 0 ปรากฏใน Slice หน้า
2// แบบนี้ก็จะถือว่า Slice หลังมีบางค่าตรงกับใน Slice หน้าแล้ว
3// การทำงานจึงคืนค่าเป็น true
4Some([]int{0, 1, 2, 3, 4, 5}, []int{0, 9})

กลุ่มฟังก์ชันสำหรับการค้นหา

ฟังก์ชันในกลุ่มนี้เน้นไปเพื่อการค้นหาค่าข้อมูล เช่น Find, IndexOf และ Sample เป็นต้น

การสร้างและใช้งาน Find

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

การเรียกใช้งาน Find ต้องทำการส่งอาร์กิวเมนต์สองค่า ค่าแรกคือ Slice ที่ต้องการค้นหาค่า ส่วนค่าที่สองคือฟังก์ชันค้นหาที่มีเงื่อนไขระบุว่าการค้นหานั้นเป็นไปตามกฎเกณฑ์ใด โดยผลลัพธ์ที่คืนจากฟังก์ชันต้องเป็น bool

Go
1// ค้นหาเลขคู่ตัวแรกใน Slice ผลลัพธ์ที่ได้คือ 2, true
2item, ok := lo.Find([]int{1, 2, 3, 4, 5}, func(i int) bool {
3 return i%2 == 0
4})

วิธีการสร้างฟังก์ชัน Find จะใช้การตรวจสอบค่าด้วยการส่งแต่ละอีลีเมนต์ใน Slice ไปทดสอบกับฟังก์ชัน ถ้าฟังก์ชันคืนค่าเป็น true แสดงว่าเราค้นหาสิ่งที่ต้องการพบแล้วจึงทำการคืนค่านั้นกลับพร้อมค่าสถานะเป็น true

Go
1func Find[T comparable](items []T, fn func(T) bool) (T, bool) {
2 for _, item := range items {
3 if fn(item) {
4 return item, true
5 }
6 }
7
8 var result T
9 return result, false
10}

การสร้างและใช้งาน Sample

Sample เป็นฟังก์ชันสำหรับการสุ่มค่าจาก Slice ที่กำหนด

Go
1// อาจสุ่มได้ค่า 1 หรือ 2 หรือ 3
2lo.Sample([]int{1, 2, 3})

วิธีการสร้างฟังก์ชันนี้จะใช้ rand.Intn เพื่อทำการสุ่มค่า

Go
1func Sample[T any](items []T) T {
2 rand.Seed(time.Now().UnixNano())
3 index := rand.Intn(len(items))
4
5 return items[index]
6}

สรุป

lo ประกอบด้วยฟังก์ชันที่ช่วยให้การเขียนโค้ดง่ายขึ้น อาศัยคุณสมบัติของ Generics ในภาษา Go จึงสนับสนุนให้ lo ทำงานกับชนิดข้อมูลแบบต่าง ๆ ได้อย่างมีประสิทธิภาพ สำหรับการใช้งาน lo เพิ่มเติม ผู้อ่านสามารถดูรายละเอียดได้จาก ที่นี่ครับ

สารบัญ

สารบัญ

  • การติดตั้ง Lo
  • กลุ่มฟังก์ชันสำหรับใช้งานกับ Slice
  • กลุ่มฟังก์ชันสำหรับใช้งานกับ Map
  • กลุ่มฟังก์ชัน Intersection Helpers
  • กลุ่มฟังก์ชันสำหรับการค้นหา
  • สรุป