สร้าง Stream API แบบ Java ด้วย Generics ในภาษา Go

Nuttavut Thongjor

บทความนี้ผู้อ่านจำเป็นต้องทราบการใช้งาน 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 ที่พร้อมสำหรับการนำข้อมูลภายในไปประมวลผลต่อดังนี้

Go
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 จึงได้โค้ดดังนี้

Go
1func ContainsSpace(s string) bool {
2 return strings.Contains(s, " ")
3}
4
5s := []string{"babel", "coder", "babel coder", "Babel Coder"}
6
7stream.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 CODER
13 })

การนิยาม struct สำหรับ Stream API

เราจะเริ่มต้นจากการสร้าง struct ในชื่อของ Stream โดยกำหนดให้ Type Parameter T มี constraint เป็น comparable ดังนั้น T ใด ๆ จะต้องสามารถใช้เครื่องหมาย == และ != ในการเปรียบเทียบค่าได้ ตัวอย่างของ T ที่ใช้งานได้ เช่น int และ string เป็นต้น

Go
1package stream
2
3type Stream[T comparable] struct {
4 Items []T
5}

เพื่อให้เราสามารถส่ง slice ไปสร้างเป็น Stream ได้โดยง่าย จึงกำหนดฟังก์ชันชื่อ New คืนค่ากลับเป็น *Stream[T] ให้กับเรา

Go
1func New[T comparable](s []T) *Stream[T] {
2 return &Stream[T]{Items: s}
3}

เมื่อเสร็จสิ้นในส่วนนี้ เราจะสามารถเรียกใช้ New จากแพคเกจของ stream ผ่าน main ได้ดังนี้

Go
1import "stream"
2
3func main() {
4 s := []string{"babel", "coder", "babel coder", "Babel Coder"}
5
6 stream.New(s)
7}

การทำงานของฟังก์ชัน New นี้จะทำการจัดเก็บ s ให้เป็นค่าของ Items ใน struct Stream นั่นเอง

การสร้างเมธอด Filter

เมธอดของ Stream ตัวแรกที่เราจะทำการสร้างคือ Filter โดยเมธอดนี้จะทำการรับฟังก์ชันเข้ามา ฟังก์ชันนี้จะนำแต่ละอีลีเมนต์ของ slice Items ใน Stream มาพิจารณาตามเงื่อนไขที่กำหนด และคืนค่า bool ออกมา กรณีที่ฟังก์ชันคืนค่า true อีลีเมนต์นั้นจะได้รับเลือกให้คงอยู่ใน Items ในทางตรงกันข้ามหากฟังก์ชันคืนค่า false อีลีเมนต์นั้นจะถูกตัดทิ้งไป หน้าตาของ Filter จึงเป็นเช่นนี้

Go
1func (s *Stream[T]) Filter(f func(T) bool) *Stream[T] {
2 var r []T
3
4 for _, v := range s.Items {
5 if f(v) {
6 r = append(r, v)
7 }
8 }
9
10 s.Items = r
11
12 return s
13}

การเรียกใช้งาน Filter เราต้องทำการส่งฟังก์ชันที่มีรูปแบบเป็น func(T) bool เสมอ เช่น

Go
1s := []string{"babel", "coder", "babel coder", "Babel Coder"}
2
3stream.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 ได้โดยตรงเช่นกัน

Go
1func ContainsSpace(s string) bool {
2 return strings.Contains(s, " ")
3}
4
5s := []string{"babel", "coder", "babel coder", "Babel Coder"}
6
7stream.New(s).
8 Filter(ContainsSpace)

การสร้างเมธอด Map

Map เป็นเมธอดสำหรับการเปลี่ยนรูปแบบจากอีลีเมนต์เก่าให้มีรูปแบบใหม่ โดยทั่วไปแล้วเราสามารถเปลี่ยนอีลีเมนต์ที่มีชนิดข้อมูล T ให้เป็นชนิดข้อมูลอื่นที่ไม่ใช่ T เช่น V แทนก็ได้ แต่เพื่อให้ตัวอย่างนี้ดูไม่ซับซ้อนเกินไป เราจะทำการสร้าง Map เพื่อเปลี่ยนจากค่าข้อมูลของชนิด T ไปเป็นค่าใหม่แต่ยังคงเป็นชนิดข้อมูล T เช่นเดิม

Go
1func (s *Stream[T]) Map(f func(T) T) *Stream[T] {
2 r := make([]T, len(s.Items))
3
4 for i, v := range s.Items {
5 r[i] = f(v)
6 }
7
8 s.Items = r
9
10 return s
11}

Map มีลักษณะการใช้ที่คล้ายกับ Filter กล่าวคือเราต้องทำการส่งฟังก์ชันให้กับมัน แต่สิ่งที่แตกต่างจาก Filter ก็คือ Filter นั้นสามารถกรองข้อมูลออกได้ผลลัพธ์การทำงานของ Filter จึงอาจทำให้จำนวนอีลีเมนต์ลดลง ผิดกับ Map ที่เป็นการเปลี่ยนข้อมูลไปเป็นอีกรูปแบบ ดังนั้นถ้าเรามีข้อมูลต้นฉบับเป็น slice จำนวน 10 ตัว ผลลัพธ์ย่อมต้องมีจำนวนข้อมูล 10 ตัวเท่าเดิม

สำหรับฟังก์ชันที่ต้องส่งไปเป็นอาร์กิวเมนต์ของ Map นั้นจะมีรูปแบบเป็น f func(T) T

ตัวอย่างการเรียกใช้งาน Map เช่น

Go
1s := []string{"babel", "coder", "babel coder", "Babel Coder"}
2
3stream.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 เราจึงสามารถใช้คำสั่งนี้แทนการเขียนโค้ดของฟังก์ชันโดยตรงได้

Go
1s := []string{"babel", "coder", "babel coder", "Babel Coder"}
2
3stream.New(s).
4 Filter(ContainsSpace).
5 Map(strings.ToUpper)

การสร้างเมธอด Distinct

คำจำกัดความของ Distinct คือการไม่ปล่อยให้ Items มีค่าอีลีเมนต์ที่ซ้ำกัน และนี่คือโค้ดของ Distinct

Go
1func (s *Stream[T]) Distinct() *Stream[T] {
2 var r []T
3 ks := make(map[T]struct{})
4
5 for _, v := range s.Items {
6 if _, ok := ks[v]; !ok {
7 ks[v] = struct{}{}
8 r = append(r, v)
9 }
10 }
11
12 s.Items = r
13
14 return s
15}

ผลลัพธ์ที่ออกจาก Distinct นั้นจะไม่มีค่าใดใน Items ที่ซ้ำกันเลย

การสร้างเมธอด ForEach

สุดท้ายเราจะทำการสร้างเมธอดที่ดูง่ายที่สุดนั่นก็คือ ForEach ForEach นั้นจะทำการรับฟังก์ชันที่ Stream จะส่งค่าอีลีเมนต์แต่ละตัวจาก Items ให้กับมัน ฟังก์ชันนี้จะดำเนินการต่ออย่างไรก็ได้โดยไม่ต้องทำการคืนค่ากลับจากฟังก์ชันแต่อย่างใด เมื่อเข้าใจเงื่อนไขนี้แล้วจึงพบว่า ForEach จะมีรูปแบบฟังก์ชันคือ func(T)

Go
1func (s *Stream[T]) ForEach(f func(T)) {
2 for _, v := range s.Items {
3 f(v)
4 }
5}

เมื่อผสานทุกเมธอดเข้าด้วยกัน นี่จึงเป็นโค้ดสุดท้ายจากความบากบั่นของเรา

Go
1// package stream
2type Stream[T comparable] struct {
3 Items []T
4}
5
6func New[T comparable](s []T) *Stream[T] {
7 return &Stream[T]{Items: s}
8}
9
10func (s *Stream[T]) Map(f func(T) T) *Stream[T] {
11 r := make([]T, len(s.Items))
12
13 for i, v := range s.Items {
14 r[i] = f(v)
15 }
16
17 s.Items = r
18
19 return s
20}
21
22func (s *Stream[T]) Filter(f func(T) bool) *Stream[T] {
23 var r []T
24
25 for _, v := range s.Items {
26 if f(v) {
27 r = append(r, v)
28 }
29 }
30
31 s.Items = r
32
33 return s
34}
35
36func (s *Stream[T]) Distinct() *Stream[T] {
37 var r []T
38 ks := make(map[T]struct{})
39
40 for _, v := range s.Items {
41 if _, ok := ks[v]; !ok {
42 ks[v] = struct{}{}
43 r = append(r, v)
44 }
45 }
46
47 s.Items = r
48
49 return s
50}
51
52func (s *Stream[T]) ForEach(f func(T)) {
53 for _, v := range s.Items {
54 f(v)
55 }
56}
57
58// package main
59func ContainsSpace(s string) bool {
60 return strings.Contains(s, " ")
61}
62
63func main() {
64 s := []string{"babel", "coder", "babel coder", "Babel Coder"}
65
66 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
  • สรุป