รูปแบบการจับของสองชิ้นให้กลายเป็นชิ้นเดียวของ Haskell (a.k.a Monoid)

Weerasak Chongnguluam
3 min readOct 18, 2017

--

Haskell เป็น Pure Functional Language ครับ ซึ่งเราสร้างกลไกการทำงานด้วยการสร้าง Function แล้วเรา Function มาประกอบกันเป็น Function ใหม่ ที่ทำหน้าที่ตามที่เราต้องการ

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

ตัวอย่าง 1) มีตัวเลข 2 ตัวเอามา + กัน

อันนี้ง่ายมากเลย เช่น 1 + 0 ได้ 1, 2 + 2 ได้ 4 จะเห็นว่า ตัวเลขสองตัว มาบวกกัน ยังคงได้ตัวเลขเหมือนเดิม

ตัวอย่าง 2) มีตัวเลข 2 ตัวเอามา * กัน

อันนี้ก็เหมือนข้างบนล่ะครับ แต่เป็นคูณ เช่น 1 * 1 = 1, 3 * 3 = 9 ตัวเลขสองตัว มาคูณกัน ยังได้ตัวเลขเหมือนเดิม

ตัวอย่าง 3) มีสตริงสองตัว เอามาต่อกัน ใช้ ++ เป็น operator

อันนี้ก็เป็นเรื่องปกติ ที่เราจะมีข้อมูลสตริงมาต่อกันในโปรแกรมเช่น “Hello” ++ “” ได้ “Hello”, “Hello” ++ “ World” ได้ “Hello World” สตริงสองตัวต่อกันก็ยังได้สตริงเหมือนเดิม

ตัวอย่าง 4) มีข้อมูลแบบ Boolean สองตัวเอามา And (&&) กัน

ข้อมูลแบบ Boolean เราก็เอามาเชื่อมกันด้วย And ได้เช่น True && True ได้ True, False && True ได้ False

ตัวอย่าง 5) มีข้อมูลแบบ Boolean สองตัวเอามา Or (||) กัน

ข้อมูลแบบ Boolean เราก็เอามาเชื่อมกันด้วย Or ได้เช่น True || False ได้ True, False && False ได้ False

และมีข้อมูลอีกหลายแบบ ที่สามารถผ่าน function หรือ operator ใดๆแล้วได้ผลกลับมาเป็นประเภทข้อมูลเดิม และแน่นอน ในโลกของ Haskell อะไรที่มันเริ่มจะทำซ้ำๆกัน มีรูปแบบซ้ำๆกัน เราสามารถทำให้มัน Abstract ขึ้น ทำให้มัน Generic ขึ้นได้ด้วย type class (ซึ่งมองคล้ายๆ interface ของ Java หรือ Go อ่ะนะ แต่เวลาออกแบบโปรแกรม คิดไม่เหมือนกันแน่นอน อย่าลอกการออกแบบ interface มาใช้กับ type class) และรูปแบบที่ยกตัวอย่างไปด้านบน Haskell มี type class มาให้แล้วชื่อ Monoid

Monoid Type Class

Monoid type class กำหนดสเปคไว้ว่า อะไรก็ตามที่จะเป็น Monoid ได้ต้องรองรับการทำงานฟังก์ชัน 3 รูปแบบนี้ แต่ตอนสร้าง type instance จริงๆเขียนแค่ mempty กับ mappend ก็พอ mconcat มีเขียน default ไว้แล้วที่ type class

class Monoid a where
mempty :: a
mappend :: a -> a -> a
mconcat :: [a] -> a

mempty คือฟังก์ชันที่ return ค่าหนึ่งของ type ที่เป็น Monoid ออกมา
mappend คือฟังก์ชันที่เราพูดถึงด้านบนครับ จับค่าของ type ที่เป็น Monoid 2 ตัวเข้ามาทำอะไรสักอย่างแล้วยังได้ type เดิม
mconcat คือเอาลิสต์ของ type ที่เป็น Monoid มาจับรวมกันให้หมดโดยรวมทีละคู่นั่นเอง

Monoid Laws

นอกจากจะต้องสร้างฟังก์ชันอย่างน้อย 2 ตัว mempty และ mappend แล้วยังมีกฎเกณฑ์บางอย่างที่ต้องทำตาม ถึงจะเป็น Monoid แม้ว่าตัวภาษา Haskell เองจะไม่บังคับกฎนี้ที่ compiler แต่ถ้าไม่ทำให้ได้ตามกฎนี้ อาจจะมี bug เกิดขึ้นได้ กฏที่ว่ามีดังนี้

Identity laws

x `mappend` mempty = x
mempty `mappend` x = x

จากกฎจะทำให้เราเห็นหน้าที่ของ mempty ละ ว่าทำไมต้องมี มันทำหน้าที่เป็น Identity value นั่นเอง คือค่าอื่นๆ เอามาทำอะไรกับมัน ก็จะได้ค่าเดิม เช่นการบวก มี 0 เป็น mempty การคูณมี 1 การรวมสตริงมี "" การเชื่อมด้วยหรือมี False การเชื่อมด้วยและมี True นั่นเอง

Associativity laws

(x `mappend` y) `mappend` z = x `mappend` (y `mappend` z)

กฎนี้นั่นคือการสลับกลุ่ม หรือ ลำดับการทำงานนั่นเอง คือเราจะจับ x กับ y ก่อนแล้วค่อย z หรือจะ y กับ z ก่อนแล้วค่อยเอา x ไปรวม จะต้องได้ค่าเท่ากัน เช่น

(1 + 2) + 3 = 1 + (2 + 3)
(1 * 2) * 3 = 1 * (2 * 3)
("Hello" + " ") + "World" = "Hello" + (" " + "World")
(True && True) && False = True && (True && False)
(True || True) || False = True || (True || False

ตัวอย่าง Haskell Monoid

เอาละ ผมจะไม่ลงรายละเอียดว่าเราจะทำให้ type เราเป็น Monoid ได้ยังไงตรงนี้ เดี๋ยวคนที่ไม่ได้เขียน Haskell จะมึนไปมากกว่านี้ เพราะจริงๆผมอยากเน้น pattern มากกว่า มาดูตัวอย่างการใช้ type ที่เป็น Monoid ใน Haskell กันเลยดีกว่า

ก่อนเริ่มตัวอย่างขอเพิ่มอีกนิดคือในโค้ดตัวอย่างไม่ได้ใช้ mappend มันดูยาวไป ผมจะใช้ <> แทนซึ่งมันก็คือ mappend นั่นแหละแต่ใช้เป็น infix ได้เลย และ type ที่ยกตัวอย่าง ต้อง import Data.Monoid ก่อนถึงใช้ได้

Sum Monoid

เนื่องจาก Monoid มันมี mappend ได้อันเดียว ทำให้จำเป็นต้องทำการสร้าง type ใหม่มีครอบตัวเลขอีกทีสำหรับแต่ละ mappend เช่นในกรณีนี้จะทำบวก mappend เลยมี Sum มาให้ เดี๋ยวตัวอย่างต่อไปจะมี Product สำหรับคูณ All สำหรับ && และ Any สำหรับ ||

x = 1 :: Sum Int
y = 2 :: Sum Int
z = 3 :: Sum Int
getSum $ x <> y <> z
result> 6

getSum คือเอา Int ออกจาก Sum ที่ครอบอยู่อีกที

Product Monoid

x = 5 :: Product Int
y = 6 :: Product Int
z = 7 :: Product Int
getProduct $ x <> y <> z
result> 210

All Monoid

x = All True
y = All True
z = All True
getAll $ x <> y <> z
result> True

Any Monoid

x = Any False
y = Any False
z = Any False
getAny $ x <> y <> z
result> False

List Monoid (In Haskell String is List of Char)

และสุดท้าย List Monoid อันนี้ไม่ต้องทำ เพราะมันมีแต่การต่อสตริงให้เป็น mappend เท่านั้น

x = "Hello"
y = ", "
z = "World"
x <> y <> z
result> "Hello, World"

เอาทั้งหมดที่อยู่ในลิสต์รวมกันด้วย mconcat

จากตัวอย่างผมใช้ x, y, z แทนค่าที่มารวมกันแล้วค่อยๆใช้ <> รวมมันทีละคู่ แต่อยากที่บอก Monoid มันมี mconcat ที่รับลิสต์ของ Monoid แล้วมันจะเอาไปรวมกันหมดโดยเรียก mappend ให้เองดังนั้นตัวอย่างข้างบนตรง x <> y <> z เขียนได้อีกแบบคือ

mconcat [x, y, z]

หมดแค่นี้เหรอ Monoid?

โอ้ยใน Haskell มี data type อีกมากมายครับ ที่ได้สร้าง instance ของ Monoid เอาไว้ ในที่นี้คือรวม package อื่นๆที่มีคนสร้างไว้ด้วยนะ ไม่ใช่เพราะ standard package

ทำได้แค่นี้อะนะ?

ถ้ามองจาก typeclass ก็ใช่เลยครับ เราใช่มันเพื่อรวมของ type เดียวกัน เข้าด้วยกันด้วยกระบวนการบางอย่าง แต่ก็อย่างที่บอกรูปแบบนี้ pattern นี้เราเห็นได้มากมาย ไม่ใช่แค่ประเภทข้อมูลที่ผมยกตัวอย่าง ความเจ๋งของมันอยู่ที่ ไม่ว่าประเภทข้อมูลมันจะซับซ้อนยังไง ขอแค่ implement Monoid เอาไว้ ในมุมคนใช้งาน เราเอามันมา mappend กัน และ mconcat กันได้เสมอ เป็นการ Abstract ความซับซ้อนออกไปจากคนใช้งาน หรือโค้ดที่จะใช้งานข้อมูลนั้นเอง

--

--