สร้าง Stream API แบบ Java ด้วย Generics ในภาษา Go
บทความนี้ผู้อ่านจำเป็นต้องทราบการใช้งาน Generics ใน Go ก่อน หากต้องการข้อมูลเพิ่มเติมสามารถอ่านได้จาก Go Generics คืออะไร? เรียนรู้การใช้งาน Generics ผ่าน Type Parameters
ในภาษา Java นั้นเรามี Stream API ที่รวมกลุ่มของข้อมูลและประมวลผลตามลำดับเพื่อให้เกิดผลลัพธ์ตามต้องการ Stream API นั้นถือเป็นหนึ่งฟีเจอร์สำคัญที่เพิ่มลูกเล่นของการเขียนโค้ดให้ดูสะอาดตาอย่างมีชั้นเชิง
ในบทความนี้เราจะใช้ภาษา Go พร้อมกับความสามารถใหม่ใน Go 1.18 คือ Generics เพื่อทดลองสร้าง Stream API อย่างง่ายกัน
เริ่มต้นจากการเรียกใช้งาน Stream API
ก่อนที่จะลงมือสร้าง Stream API เรามาส่องความสามารถของ API นี้กันก่อนครับว่าสามารถทำอะไรได้บ้าง
สมมติเรามี slice ตัวหนึ่งในชื่อของ s ที่พร้อมสำหรับการนำข้อมูลภายในไปประมวลผลต่อดังนี้
1s := []string{"babel", "coder", "babel coder", "Babel Coder"}
สิ่งที่เราอยากประมวลผลบน s เป็นไปตามขั้นตอนตามรายการต่อไปนี้
- กรองแต่ละอีลีเมนต์ใน slice เพื่อเอาเฉพาะข้อความที่มีช่องว่าง ผลลัพธ์จากขั้นตอนนี้ s จะเหลือเพียง
[]string{"babel coder", "Babel Coder"}
- นำผลลัพธ์ s จากขั้นตอนแรกมาแปลงแต่ละข้อความให้เป็นอักษรตัวใหญ่ทั้งคำ คำตอบจากขั้นตอนนี้จึงเป็น
[]string{"BABEL CODER", "BABEL CODER"}
- ขั้นตอนก่อนหน้าพบว่า s สุดท้ายจะเกิดคำว่า BABEL CODER ซ้ำกันสองครั้ง เราจึงจะทำการตัดคำซ้ำ สุดท้ายแล้วจึงเหลือเพียง
[]string{"BABEL CODER"}
- เมื่อได้ s เป็น slice สุดท้ายที่มีรูปโฉมตามต้องการ เราจึงนำข้อมูลนี้มาวนลูปเพื่อพิมพ์ค่าผลลัพธ์
Stream API คือกลุ่มของข้อมูลที่ถูกประมวลผลตามลำดับ เมื่อเรามีสี่ขั้นตอนจึงต้องนิยามการประมวลผลทั้ง 4 ดังนี้
- การกรองข้อมูล เราจะสร้างเมธอดของ Stream ชื่อ Filter
- การแปลงจากอีลีเมนต์หนึ่งไปเป็นอีกรูปแบบใช้เมธอด Map ของ Stream
- เพื่อป้องกันไม่ให้ข้อมูลซ้ำจึงนิยามเมธอด Distinct ใน Stream API
- สุดท้ายการวนลูปเพื่อพิมพ์ค่าจะกระทำผ่านเมธอด ForEach
นำรูปแบบการประมวลผลทั้งสี่เข้าคู่กับ Stream API จึงได้โค้ดดังนี้
1func ContainsSpace(s string) bool {2 return strings.Contains(s, " ")3}45s := []string{"babel", "coder", "babel coder", "Babel Coder"}67stream.New(s).8 Filter(ContainsSpace). // []string{"babel coder", "Babel Coder"}9 Map(strings.ToUpper). // []string{"BABEL CODER", "BABEL CODER"}10 Distinct(). // []string{"BABEL CODER"}11 ForEach(func(w string) {12 fmt.Println(w) // BABEL CODER13 })
การนิยาม struct สำหรับ Stream API
เราจะเริ่มต้นจากการสร้าง struct ในชื่อของ Stream โดยกำหนดให้ Type Parameter T มี constraint เป็น comparable
ดังนั้น T ใด ๆ จะต้องสามารถใช้เครื่องหมาย ==
และ !=
ในการเปรียบเทียบค่าได้ ตัวอย่างของ T ที่ใช้งานได้ เช่น int และ string เป็นต้น
1package stream23type Stream[T comparable] struct {4 Items []T5}
เพื่อให้เราสามารถส่ง slice ไปสร้างเป็น Stream ได้โดยง่าย จึงกำหนดฟังก์ชันชื่อ New คืนค่ากลับเป็น *Stream[T]
ให้กับเรา
1func New[T comparable](s []T) *Stream[T] {2 return &Stream[T]{Items: s}3}
เมื่อเสร็จสิ้นในส่วนนี้ เราจะสามารถเรียกใช้ New จากแพคเกจของ stream ผ่าน main ได้ดังนี้
1import "stream"23func main() {4 s := []string{"babel", "coder", "babel coder", "Babel Coder"}56 stream.New(s)7}
การทำงานของฟังก์ชัน New นี้จะทำการจัดเก็บ s ให้เป็นค่าของ Items ใน struct Stream นั่นเอง
การสร้างเมธอด Filter
เมธอดของ Stream ตัวแรกที่เราจะทำการสร้างคือ Filter โดยเมธอดนี้จะทำการรับฟังก์ชันเข้ามา
ฟังก์ชันนี้จะนำแต่ละอีลีเมนต์ของ slice Items
ใน Stream มาพิจารณาตามเงื่อนไขที่กำหนด และคืนค่า bool ออกมา
กรณีที่ฟังก์ชันคืนค่า true อีลีเมนต์นั้นจะได้รับเลือกให้คงอยู่ใน Items ในทางตรงกันข้ามหากฟังก์ชันคืนค่า false อีลีเมนต์นั้นจะถูกตัดทิ้งไป
หน้าตาของ Filter จึงเป็นเช่นนี้
1func (s *Stream[T]) Filter(f func(T) bool) *Stream[T] {2 var r []T34 for _, v := range s.Items {5 if f(v) {6 r = append(r, v)7 }8 }910 s.Items = r1112 return s13}
การเรียกใช้งาน Filter เราต้องทำการส่งฟังก์ชันที่มีรูปแบบเป็น func(T) bool
เสมอ เช่น
1s := []string{"babel", "coder", "babel coder", "Babel Coder"}23stream.New(s).4 Filter(func (w string) bool {5 return strings.Contains(w, " ")6 })
ตัวอย่างข้างต้นเราส่งฟังก์ชันที่มีรูปแบบเป็น func (s string) bool
ไปยัง Filter
เมื่อ s ถูกกำหนดให้เป็น Items ใน Stream การทำงานของ Filter จึงวนลูปรอบ Items หรือ s
โดยรอบแรกของการทำงาน w จะถูกกำหนดให้เป็น babel แล้วนำไปเช็คเงื่อนไขจากฟังก์ชัน
การทำงานรอบแรกนี้พบว่า babel ไม่ได้ประกอบด้วยช่องว่าง ผลลัพธ์จากฟังก์ชันจึงเป็น false
ดังนั้น babel จะไม่เป็นผลลัพธ์ใน Items อีกต่อไป เมื่อการทำงานวนรอบมาถึง babel coder
คำนี้มีช่องว่างกลางคำผลลัพธ์จากฟังก์ชันจึงคืนกลับเป็น true เป็นผลให้ babel coder
ได้รับเลือกให้ไปต่อด้วยการใส่คำนี้ใน Items ของ Stream
กรณที่เราแยกฟังก์ชันออกมาข้างนอก เราย่อมสามารถส่งชื่อฟังก์ชันผ่าน Filter ได้โดยตรงเช่นกัน
1func ContainsSpace(s string) bool {2 return strings.Contains(s, " ")3}45s := []string{"babel", "coder", "babel coder", "Babel Coder"}67stream.New(s).8 Filter(ContainsSpace)
การสร้างเมธอด Map
Map เป็นเมธอดสำหรับการเปลี่ยนรูปแบบจากอีลีเมนต์เก่าให้มีรูปแบบใหม่ โดยทั่วไปแล้วเราสามารถเปลี่ยนอีลีเมนต์ที่มีชนิดข้อมูล T ให้เป็นชนิดข้อมูลอื่นที่ไม่ใช่ T เช่น V แทนก็ได้ แต่เพื่อให้ตัวอย่างนี้ดูไม่ซับซ้อนเกินไป เราจะทำการสร้าง Map เพื่อเปลี่ยนจากค่าข้อมูลของชนิด T ไปเป็นค่าใหม่แต่ยังคงเป็นชนิดข้อมูล T เช่นเดิม
1func (s *Stream[T]) Map(f func(T) T) *Stream[T] {2 r := make([]T, len(s.Items))34 for i, v := range s.Items {5 r[i] = f(v)6 }78 s.Items = r910 return s11}
Map มีลักษณะการใช้ที่คล้ายกับ Filter กล่าวคือเราต้องทำการส่งฟังก์ชันให้กับมัน แต่สิ่งที่แตกต่างจาก Filter ก็คือ Filter นั้นสามารถกรองข้อมูลออกได้ผลลัพธ์การทำงานของ Filter จึงอาจทำให้จำนวนอีลีเมนต์ลดลง ผิดกับ Map ที่เป็นการเปลี่ยนข้อมูลไปเป็นอีกรูปแบบ ดังนั้นถ้าเรามีข้อมูลต้นฉบับเป็น slice จำนวน 10 ตัว ผลลัพธ์ย่อมต้องมีจำนวนข้อมูล 10 ตัวเท่าเดิม
สำหรับฟังก์ชันที่ต้องส่งไปเป็นอาร์กิวเมนต์ของ Map นั้นจะมีรูปแบบเป็น f func(T) T
ตัวอย่างการเรียกใช้งาน Map เช่น
1s := []string{"babel", "coder", "babel coder", "Babel Coder"}23stream.New(s).4 Filter(ContainsSpace).5 Map(func(w string) string {6 return strings.ToUpper(w)7 })
Map อาศัยการทำงานแบบวนรอบเช่นเดียวกับ Filter ในแต่ละรอบจะส่งค่าแต่ละอีลีเมนต์ใน Items มาให้กับฟังก์ชันที่ส่งเข้าไปใน Map ค่าที่คืนกลับจากฟังก์ชันจะเป็นผลลัพธ์ของ slice Items ในลำดับถัดไป
ตัวอย่างข้างต้นผลลัพธ์จากการทำงานของ Filter คือ []string{"babel coder", "Babel Coder"}
รอบแรกของการทำงาน Map จึงได้ค่า w เป็น babel coder
เมื่อผ่านฟังก์ชันค่าผลลัพธ์จะเป็น BABEL CODER
รอบสุดท้าย w เป็น Babel Coder ผ่านฟังก์ชันแล้วได้ค่าใหม่เป็น BABEL CODER
เช่นกัน
ท้ายที่สุดเมื่อกระบวนการทำงานสมบูรณ์ ผลลัพธ์จาก Map จะยังผลใหม่ Items ของ Stream มีค่าเป็น []string{"BABEL CODER", "BABEL CODER"}
เราทราบอยู่แล้วว่า strings.ToUpper
มีรูปแบบฟังก์ชันเป็น func(string) string
เราจึงสามารถใช้คำสั่งนี้แทนการเขียนโค้ดของฟังก์ชันโดยตรงได้
1s := []string{"babel", "coder", "babel coder", "Babel Coder"}23stream.New(s).4 Filter(ContainsSpace).5 Map(strings.ToUpper)
การสร้างเมธอด Distinct
คำจำกัดความของ Distinct คือการไม่ปล่อยให้ Items มีค่าอีลีเมนต์ที่ซ้ำกัน และนี่คือโค้ดของ Distinct
1func (s *Stream[T]) Distinct() *Stream[T] {2 var r []T3 ks := make(map[T]struct{})45 for _, v := range s.Items {6 if _, ok := ks[v]; !ok {7 ks[v] = struct{}{}8 r = append(r, v)9 }10 }1112 s.Items = r1314 return s15}
ผลลัพธ์ที่ออกจาก Distinct นั้นจะไม่มีค่าใดใน Items ที่ซ้ำกันเลย
การสร้างเมธอด ForEach
สุดท้ายเราจะทำการสร้างเมธอดที่ดูง่ายที่สุดนั่นก็คือ ForEach
ForEach นั้นจะทำการรับฟังก์ชันที่ Stream จะส่งค่าอีลีเมนต์แต่ละตัวจาก Items ให้กับมัน
ฟังก์ชันนี้จะดำเนินการต่ออย่างไรก็ได้โดยไม่ต้องทำการคืนค่ากลับจากฟังก์ชันแต่อย่างใด
เมื่อเข้าใจเงื่อนไขนี้แล้วจึงพบว่า ForEach จะมีรูปแบบฟังก์ชันคือ func(T)
1func (s *Stream[T]) ForEach(f func(T)) {2 for _, v := range s.Items {3 f(v)4 }5}
เมื่อผสานทุกเมธอดเข้าด้วยกัน นี่จึงเป็นโค้ดสุดท้ายจากความบากบั่นของเรา
1// package stream2type Stream[T comparable] struct {3 Items []T4}56func New[T comparable](s []T) *Stream[T] {7 return &Stream[T]{Items: s}8}910func (s *Stream[T]) Map(f func(T) T) *Stream[T] {11 r := make([]T, len(s.Items))1213 for i, v := range s.Items {14 r[i] = f(v)15 }1617 s.Items = r1819 return s20}2122func (s *Stream[T]) Filter(f func(T) bool) *Stream[T] {23 var r []T2425 for _, v := range s.Items {26 if f(v) {27 r = append(r, v)28 }29 }3031 s.Items = r3233 return s34}3536func (s *Stream[T]) Distinct() *Stream[T] {37 var r []T38 ks := make(map[T]struct{})3940 for _, v := range s.Items {41 if _, ok := ks[v]; !ok {42 ks[v] = struct{}{}43 r = append(r, v)44 }45 }4647 s.Items = r4849 return s50}5152func (s *Stream[T]) ForEach(f func(T)) {53 for _, v := range s.Items {54 f(v)55 }56}5758// package main59func ContainsSpace(s string) bool {60 return strings.Contains(s, " ")61}6263func main() {64 s := []string{"babel", "coder", "babel coder", "Babel Coder"}6566 stream.New(s).67 Filter(ContainsSpace).68 Map(strings.ToUpper).69 Distinct().70 ForEach(func(w string) {71 fmt.Println(w)72 })73}
สรุป
การใช้งาน Stream ช่วยลดการประกาศฟังก์ชันเพื่อวนลูปหลาย ๆ ครั้ง ทั้งยังทำให้การประมวลผลข้อมูลดูเป็นกลุ่มก้อนและมีลำดับชัดเจนมากยิ่งขึ้น ปัจจุบันเรามีไลบรารี่ต่าง ๆ มากมายที่สามารถทำสิ่งนี้ได้ เช่น RxGo หากท่านใดสนใจจะลองหยิบจับไลบรารี่ซักตัวมาลองลิ้มชิมรสก็ได้เช่นกันครับ
สารบัญ
- เริ่มต้นจากการเรียกใช้งาน Stream API
- การนิยาม struct สำหรับ Stream API
- การสร้างเมธอด Filter
- การสร้างเมธอด Map
- การสร้างเมธอด Distinct
- การสร้างเมธอด ForEach
- สรุป