21 Monadsในนิยามของโปรแกรมเมอร์ (Sketch)
โปรแกรมเมอร์ได้พัฒนาตำนานแทบทั้งหมดรอบๆmonad มันควรที่จะเป็นแนวคิดที่นามธรรมและยากที่สุดในการเขียนโปรแกรมเมอร์ ได้มีคนที่“เช้าใจมัน”และคนที่ไม่ สำหรับหลายคน ในตอนที่พวกเขาเข้าใจแนวคิดของmonadนั้นเหมือนกับประสบการณ์ที่เหมือนกับเวทมนตร์ monadได้ดึงส่วนประกอบสำคัญของการสร้างหลากหลายรูปแบบที่เราแค่ไม่มีการเปรียบเทียบที่ดีในทุกๆวันของเรา เราถูกลดไปยังการคลำหาในที่มืด เหมือนกับคมที่ตาบอดที่กำลังแตะส่วนต่างๆของช้างแล้วประกาศอย่างเต็มที่ว่า: “นั้นคือเชือก” “นั้นคือขอนไม้” “นั้นคือburrito”
ให้ผมได้บอกอย่างเป็นทางการ ความเป็นเวทมนตร์รอบๆmonadนั้นเป็นผลมาจากความเข้าใจผิด monadนั้นเป็นแนวคิดที่เรียบง่ายมาก มันคือความหลากหลายของการใช้งานของmonadที่มำให้เกิดความสับสน
ในฐานะส่วนของการค้นคว้าสำหรับpostนี้ ผมได้ค้นหาเทปพันท่อและการใช้งานของมันห นี้คือตัวอย่างเล็กๆของของที่คุณสามารถใช้งานมันได้
- ปิดท่อ
- แก้ไขการรั่วของCO\(_2\)บนApollo 13
- รักษาหูด
- แก้ไขปัญหาของการวางสายiPhone 4ของApple
- ตัดเสื้องานprom
- สร้างสะพานแขวน
ในตอนนี้จินตนาการว่าคุณไม่รู้ว่าเทปพันท่อคืออะไรและคุณพยายามที่จะเข้าใจมันโดยอาศัยlistนี้ โชคดีนะ!
ดังนั้นผมอยากที่จะเพิ่มอีกชิ้นไปยังสิ่งที่เก็บรวบรวมไว้ของclichésอย่าง”monadนั้นเหมือน…” monadนั้นเหมือนเทปพันท่อ การใช้งานของมันนั้นกว้างขวางแต่หลักการของมันนั้นง่ายมากๆคือมันเชื่อมส่งของต่างๆร่วมกัน ให้แม่นยำกว่านี้คือมันบีบอัดของต่างๆ
สิ่งนี้อธิบายในบางส่วนของความยุ่งยากสำหรับโปรแกรมเมอร์ โดยเฉพาะคนที่มาจกพื้นฐานของimperative ที่มีกับการทำความเข้าใจmonad ปัญหานี้คือการที่ว่าเราไม่ได้ชินกับการคิกถึงการเขียนโปรแกรมในแบบของการประกอบกันของfunction นั้นเป็นสิ่งที่เข้าใจได้ เรามักจะให้ชื่อกับค่าระหว่างทางแทนที่จะสงมันโดยตรงจากfunctionไปยังfunction เราก็ทำการinlineส่วนสั้นๆของของโค้ดที่ติดกันแทนที่จะabstractมันไปยังfunctionช่วยเหมือ ในที่นี้การเขียนในรูปแบบของimperativeของfunctionเกี่ยวกับความยาวของvectorในC
ว่า
double vlen(double * v) {
double d = 0.0;
int n;
for (n = 0; n < 3; ++n)
+= v[n] * v[n];
d return sqrt(d);
}
เทียบสิ่งนี้กับรูปแบบของHaskellที่มีstyleที่ทำให้การประกอบfunctionชัดเจนมากขึ้น
= sqrt . sum . fmap (flip (^) 2) vlen
(ในที่นี้ ในการทำให้สิ่งต่างๆนั้นลึกลับมากขึ้น ผมได้ใช้งานบางส่วนของopertorแบบexponentiation(^)
โดยการการตั้งค่าargumentที่สองของมันให้เป็น2
)
ผมไม่ได้เสนอว่ารูปแบบของHaskellที่ไม่มีpointนั้นดีกว่าตลอด แค่ว่าการประกอบกันของfunctionที่อยู่ภายใต้ของทุกๆอย่างที่เราทำในการเขียนโปรแกรม และถึงแม้เราจะจริงๆแล้วได้ประกอบfunctionอย่างง่ายดาย Haskellได้พยายามอย่างมากในการเตรียมการรูปแบบsyntaxแบบimperativeที่ถูกเรียกว่าเครื่องหมายdo
สำหรับการประกอบกันของแบบmonad เราจะได้เห็นในภายหลัง แต่ในตอนแรก ให้ผมได้อธิบาย ทำไมเราต้องการประกอบกันของแบบmonadในตอนแรก
21.1 CategoryแบบKleisli
ก่อนหน้านี้เราได้มาถึงที่monadแบบwriterโดยการประดับfunctionทั่วไป การประดับโดยเฉพาะสามารถทำได้โดยการจับคู่ค่าreturnของมันกับstringหรือโดยทั่วไปกับสมาชิกของmonoid เราสามารถมองเห็นสิ่งนี้ในฐานะการประดับแบบนี้คือfunctorอย่าง
newtype Writer w a = Writer (a, w)
instance Functor (Writer w) where
fmap f (Writer (a, w)) = Writer (f a, w)
หลังจากนี้เราได้หาวิธีการของการประกอบของfunctionที่ถูกประดับหรือลูกศรของKleisliที่คือfunctionของในรูปแบบของ
-> Writer w b a
มันคือข้างในของการประกอบกันที่เราได้เขียนการรวมกันของlogต่างๆ
ในตอนนี้เรานั้นได้พร้อมแล้วสำหรับนิยามที่ทั่วไปของcategoryแบบKleisli เราได้เริ่มด้วยcategory\(\textbf{C}\)และendofunctor\(m\) CategoryแบบKleisliที่ตรงอย่าง\(\textbf{K}\)มีวัตถุเดียวกันกับ\(\textbf{C}\)แต่morphismของมันนั้นแตกต่าง morphismระหว่างสองวัตถุ\(a\)และ\(b\)ใน\(\textbf{K}\)นั้นถูกเขียนในฐานะmorphssimอย่าง
\[ a\rightarrow mb \]
ในcategoryดั้งเดิม มันสำคัญในการจำไว้ว่าเรามองลูกศรแบบKleisliใน\(\textbf{K}\)ในฐานะmorphismระหว่าง\(a\)และ\(b\)และไม่ระหว่าง\(a\)และ\(mb\)
ในตัวอย่างของเรา\(m\)ถูกระบุให้เป็นพิเศษไปยังWriter w
สำหรับบางmonoidที่คงที่w
ลูกศรแบบKleisliก่อให้เกิดcategoryถ้าเราสามารถนิยามการประกอบกันที่ถูกต้องสำหรับพวกมัน ถ้าได้มีการประกอบกันที่สามารถสลับตำแหน่งและมีลูกศรแบบidentityสำหนับทุกๆวัตถุ แล้วfunctor\(m\)นั้นถูกเรียกว่าmonadและcategoryที่เป็นผลลัพธ์ถูดเรียกว่าcategoryแบบKleisli
ในHaskellการประกอบกันแบบKleisliนั้นถูกนิยามโดนการใช้fish operator>=>
และลูกศรแบบidentityที่คือfunctionแบบpolymorphicที่ถูกเรียกว่าreturn
ในที่นี้คือนิยามของmonadโดยการใช้การประกอบกันแบบKleisli
class Monad m where
(>=>) :: (a -> m b) -> (b -> m c) -> (a -> m c)
return :: a -> m a
จงจำไว้ว่าได้มีหลากหลายวิธีที่เท่ากันของการนิยามmonadและ สิ่งนี้คือนั้นไม่ใช่สิ่งที่สำคัญในecosystemของHaskell ผมชอบมันสำหรับความง่ายของมันทางแนวคิดและความเข้าใจที่มันให้มา แค่ได้มีนิยามอื่นๆที่มีความสะดวกมากกว่าในการเขียนโปรแกรม เราจะพูดเกี่ยวกับพวกมันในอีกไม่ช้า
ในการกำหนดนี้ กฏของmonadนั้นง่ายมากๆในการแสดงออกมา พวกมันไม่สามารถถูกเขียนในHaskell แต่พวกกันสามารถถูกสำหรับสำหรับการให้เหตุผลทางสมการ พวกมันคือแค่กฏการประกอบกันแบบมาตรฐานสำหรับcategoryแบบKleisli
(f >=> g) >=> h = f >=> (g >=> h) -- associativity
return >=> f = f -- left unit
f >=> return = f -- right unit
นิยามแบบนี้ก็ได้แสดงถึงว่าmonadคือจริงๆอะไร มันคือวิธีการของการประกอบของfunctionที่มีการประดับ มันนั้นไม่เกี่ยวกับผลข้างเคียงหรือสถานะ มันเกี่ยวกับการประกอบกัน แล้วในสิ่งที่เราจะเหห็นหลังจากนี้ functionที่มีการประดับอาจจะถูกใช้ในการแสดง ผลลัพธ์หรือสถานะที่หลายหลายแต่นั้นไม่ใช่สิ่งที่monadเป็นสำหรับ monadนั้นคือเทปพันท่อที่ผูกปลายหนึ่งของfunctionที่มีการประดับไปยังอีกปลายหนึ่งของfunctionที่มีการประดับ
กลับไปยังตัวอย่างของWriter
ของเรานั้นคือ functionที่ทำการlogging(ลูกศรต่างๆสำหรับWriter
functor)ก่อให้เกิดcategoryเพราะว่าWriter
นั้นคือmonad
instance Monoid w => Monad (Writer w) where
>=> g = \a ->
f let Writer (b, s) = f a
Writer (c, s') = g b
in Writer (c, s `mappend` s')
return a = Writer (a, mempty)
กฏของmonadสำหรับWriter w
นั้นถูกบรรลุตราบเท่าทีกฏของmonoidสำหรับw
นั้นถูกบรรลุ(พวมมันไม่สามารถบังคับในHaskellด้วย)
ได้มีลูกศรKleisliที่มีประโยชน์ถูกนิยามสำหรับmonadWriter
ที่เรียกว่าtell
วัตถุประสงค์เดียวของมันคือการเพิ่มargumentของมันไปยังlog
tell :: w -> Writer w ()
= Writer ((), s) tell s
เราจะใช้มันภายหลังในฐานะตัวต่อสำหรับอีกfunctionแบบmonadต่างๆ
21.2 กายภาพของปลา
ในตอนการเขียนfish operatorสำหรับmonadที่แตกต่างคุณจับสังเกตว่าหลายๆโค้ดนั้นช้ำไปช้ำมาและสามารถถูกแยกออกมาอย่างง่ายดาย ในการเรื่ม การประกอบกันแบบKleisliของสองfunctionต้องreturn functionดังนั้นการเขียนของมันก็อาจจะเริ่มด้วยlambdaที่นำargumentของtypea
(>=>) :: (a -> m b) -> (b -> m c) -> (a -> m c)
>=> g = \a -> ... f
สิ่งเดียวที่เราสามารถทำได้กับargumentนี้คือการส่งมันไปยังf
>=> g = \a -> let mb = f a
f in ...
ในจุดนี้เราได้สร้างผลลัพธ์ของtypem c
ในการที่มีวัตถุของtypem b
อยู่แล้วและfunctiong :: b -> m c
เรามานิยามดunctionที่ทำมันสำหรับเรา functionนี้ถูกเรียกว่าbind(เชื่อม)และมักจะถูกเขียนในรูปแบบของopeartorแบบinfix
(>>=) :: m a -> (a -> m b) -> m b
ในทุกๆmonadแทนที่จะนิยามfish operatorเราอาจจะนิยามbindแทนที่ ในความเป็นจริงแล้วนิยามHaskellมาตรฐานใช้bind
class Monad m where
(>>=) :: m a -> (a -> m b) -> m b
return :: a -> m a
นี้คือนิยามของbindสำหรับmonadWriter
Writer (a, w)) >>= f = let Writer (b, w') = f a
(in Writer (b, w `mappend` w')
มันนั้นแน่นอนว่าสั้นกว่านิยามของfish operator
มันเป็นไปได้ที่จะทำการผ่าตัดbind โดยการใช้งานของความจริงที่ว่าm
คือfunctor เราสามารถที่จะใช้fmap
ในการใช้งานของfunctiona -> m b
ไปยังเนื้อหาของm a
สิ่งนี้จะเปลียนa
ไปยังm b
ดังนั้นผลที่ตามมาขอการใช้งานนี้คือของtypem (m b)
สิ่งนี้ไม่ไช่สิ่งที่เราต้องการเป๊ะๆ (เราต้องการผลของtypem b
)แต่เราก็เขาใกล้แล้ว สิ่งที่เราต้องการคือfunctionที่รวบรวมหรือทำให้แบนของการในm
มาใช้สองครั้ง functionแบบนี้ถูกเรียกว่าjoin
join :: m (m a) -> m a
ในการใช้join
เราสามารถเขียนbind
ใหม่ว่า
>>= f = join (fmap f ma) ma
นั้นนำเราไปยังทางเลือกที่สามของการนิยามmonad
class Functor m => Monad m where
join :: m (m a) -> m a
return :: a -> m a
ในที่นี้เราได้ ต้องการอย่างเปิดเผยว่าm
นั้นเป็นFunctor
เราไม่ต้องที่จะทำแบบนั้นในนิยามสองตัวที่ผ่านมาของmonad นั้นก็เพราะว่าconstructorm
ของtypeใดๆก็ตามนั้นรับรองoperatorแบบfishหรือbindนั้นเป็นfunctorแบบอัตโนมัติ ตัวอย่างเช่น มันเป็นไปได้ที่จะนิยามfmap
ในรูปแบบของbindและreturn
fmap f ma = ma >>= \a -> return (f a)
เพื่อให้สมบูรณ์ ในที่นี้คือjoin
สำหรับmonadแบบWriter
join :: Monoid w => Writer w (Writer w a) -> Writer w a
Writer ((Writer (a, w')), w)) = Writer (a, w `mappend` w') join (
21.3 เครื่องหมายdo
ในหนึ่งทางของการเขียนโค้ดในการใช้monadคือการทำงานกับลูกศรKleisli (ประกอบพวกมันในการใช้fish operator) การเขียนโปรแกรมแบบนี้นั้นคือการgeneralizeของรูปแบบpoint-free โค้ดpoint-freeนั้นกระชับและมักจะค่อนข้างสละสลวย แม้ว่าโดยทั่วไปแล้วมันสามารถยากในการที่จะเข้าใจ หรือแทบจะลึกลับ นั้นคือทำไมโปรแกรมเมอร์ส่วนใหญ่ชอบมากกว่าที่จะให้ชื่อไปยังargumentของfunctionและค่าที่อยู่ระหว่างทาง
ในการทำงานกับmonad มันหมายความว่า การให้ความสำคัญกับbind operatorมากกว่าfish operator Bindนั้นนำค่าแบบmonadและreturnค่าของmonad โปรแกรมเมอร์ส่อาจจะเลือกชื่อของค่าต่างๆเหล่านี้ แต่นั้นแทบจะไม่เป็นการทำให้ดีขึ้น สิ่งที่เราต้องการจริงๆคือการแกล้วว่าเรานั้นกำลังทำงานกับค่าทั่วๆไปไม่ใช่ภาชนะแบบmonadที่ทำการencapsulateพวมมันเอาไว้ นั้นคือวิธีการที่codeแบบimperativeใช้งานได้ (ผลข้างเคียง) อย่างเช่นการupdating logแบบglobalนั้นแทบทั้งหมดถูกปิดบังอยู่จากภาพ และนั้นคือสิ่งที่เครื่องหมายdo
นั้นได้ทำในHaskell
คุณอาจจะสงสัยหว่า ทำไมต้องใช้monadเลยฦ ถ้าเราต้องการที่จะทำให้ผลข้างเคียงล่องหน ทำไมเราไม่อยู่กับภาษาแบบimerative? คำตอบคือว่าmonadให้เราได้ควบคุมผลข้างเคียงที่ดีกว่ามากๆ ตัวอย่างเช่นlogในmonadWriter
ที่ส่งจากfunctionไปยังfunctionและไม่เคยที่จะแสดงออกมาอยางสากล ไม่มีความเป็นไปได้ของการบิดเบือนของlogหรือการสร้างdata race แล้วก็โค้ดแบบmonadนั้นแบ่งเขตและล้อมรอบออกจากส่วนที่เหลือของโปรแกรม
เครื่องหมายdo
นั้นแค่เป็นการแต่งsyntaxสำหรับการประกอบกันแบบmonad บนผิวนอก มันดูเหมือนอย่างมากกับโค้ดแบบimperativeแต่มันแปลโดยตรงกับ การต่อเนื่องกันของการแสดงออกแบบbindsและlambda
ตัวอย่างเช่น นำตัวอย่างที่เราได้ใช้มาก่อนหน้านี้ในการแสดงถึงการประกอบกันของลูกศรKleisliในmonadWriter
โดยการใช้นิยามของเราตอนนี้ มันอยาจะถูกเขียนใหม่ในฐานะ
process :: String -> Writer String [String]
= upCase >=> toWords process
functionนั้เปลี่ยนทุกๆตัวอักษรในstring inputไปยังตัวพิมพ์ใหญ่และแยกมันไปยังคำต่างๆ และในขณะนั้นสร้างlogของการกะทำของมัน
ในเครื่องหมายของdo
มันควรที่จะดูเหมือนสิ่งนี้
= do
process s <- upCase s
upStr toWords upStr
ในที่นี้ upStr
คือแค่String
ถึงแม้upCase
จะสรา้งWriter
มา
upCase :: String -> Writer String String
= Writer (map toUpper s, "upCase ") upCase s
นั้นก็เพราะว่าส่วนของdo
นั้นถูกแปลง(ลบการแต่งของsyntax)โดยcomplierไปยัง
=
process s >>= \upStr ->
upCase s toWords upStr
ผลแบบmonadของupCase
นั้นผูกกับlambdaที่นำString
มันคือชื่อของstringนี้ที่แสดงออกมาในส่วนของdo
ในตอนที่อ่านบรรทัดนี้
<- upCase s upStr
เราเรียกว่าupStr
ได้ผลลัพธ์ของupCase s
ในรูปแบบimparativeเทียมนั้นชัดเจนมากขึ้นในตอนที่เราinlinetoWords
เราแทนที่มันกับการเรียงของไปยังtell
ที่logของstring”toWords
“ตามด้วยโดยการเรียกไปยังreturn
กับผลลัพธ์ของการแยกของstringupStr
โดยการใช้words
สังเกตว่าwords
นั้นคือfunctionที่ทำงานบนstring
= do
process s <- upCase s
upStr "toWords "
tell return (words upStr)
ในที่นี้ แต่ละบรรทัดในส่วนของdoนำแสนอbindที่อยู่ข้างในโค้ดที่ถูกแปลง(ลบการแต่งของsyntax)
=
process s >>= \upStr ->
upCase s "toWords " >>= \() ->
tell return (words upStr)
สังเกตว่าtell
สร้างค่าของunitดังนั้นมันไม่ต้องถูกส่งไปยังlambdaที่ตามมา โดยไม่สนใจเนื้อหาของผลลัพธ์แบบmonad (แต่ไม่ผลของมัน ในที่นี้คือการให้ไปยังlog)นั้นค่อนข้างทั่วไป ดังนั้นได้มีoperatorพิเศษในการแทนที่bindในกรณีนี้
(>>) :: m a -> m b -> m b
>> k = m >>= (\_ -> k) m
การแปลง(ลบการแต่งของsyntax)จริงๆของโค้ดของเราดูเหมือนแบบนี้
=
process s >>= \upStr ->
upCase s "toWords " >>
tell return (words upStr)
โดยทั่วไปแล้วส่วนของdo
ประกอบด้วยบรรทัดของ(หรือส่วนย่อย)ที่ใช้ลูกศรด้านช้ายในการนำเสนอชื่อใหม่ที่มีอยู่ในส่วนอื่นๆของcodeหรือการดำเนินการ สำหรับผลข้างเคียงอย่างสิ้นเชิง Bind operatorนั้นเป็นนัยระหว่างบรรทัดของโค้ด โดยบังเอิญแล้ว มันเป็นไปได้ในHaskellในการแทนที่การจัดรูปแบบในส่วนของdo
กับวงเล็บและsemicolons มันให้เหตุผลสำหรับการอธิบายmonadในวิธีของการoverloading semicolon
สังเกตได้ว่าการที่lambdaอยู่ในlambdaและbind operatorในการถูกแปลง(ลบการแต่งของsyntax)เครื่องหมายdo
ได้มีผลลัพธ์ของการมีอิทธิพลต่อการดำเนินการของส่วนของdo
ตามมาจากผลของแต่ละบรรทัด คุณสมบัตินี้สามารถถูกใช้ในการนำเข้ามา โครงสร้างการควบคุมที่ชับช้อนตัวอย่างเช่น ในการเลียนแบบข้อยกเว้น
น่าสนใจอย่างยิ่ง ความเท่ากันของเครื่องหมายdo
ได้เจอการใช้งานของมันในภาษาแบบimperativeโดยเฉพาะC++ ผมได้พูดเกี่ยวกับfunctionที่เริ่มต้นใหม่ได้หรือcoroutines มันไม่เป็นความลับที่ว่าfuntureของC++ก่อให้เกิดmonad1มันคือตัวอย่างmonadแบบcontinuation ที่เราจะพูดถึงในอีกไม่ช้า ปัญหากับmonadแบบcontinuation ที่พวกมันนั้นยากที่จะประกอบกัน ในHaskell เราใช้เครื่องหมายdo
ในการแปลงความวุ่นวายของ”handlerของผมจะเรียกhandlerของคุณ” ไปยังบางอย่างที่ดูเหมือนโค้ดที่ต่อเนื่องกัน
Functionที่ressumableทำให้การแปรเปลี่ยนเป็นไปได้ในC++และในกลไกเดียวกันสามารถถูกใช้งานในการแปลงloopช้อนไปช้อนมาที่ยุ่งเหยิง2ไปยังlist comprehensionหรือ”generator”ที่จริงๆแล้วคือเครื่องหมายdo
สำหรับmonadของlist ถ้าไม่มีการabstractionที่ทำให้monadเป็นหนึ่งเดียว แต่ละปัญหาเหล่านี้นั้นมักจะถูกแก้ใขโดยการให้การส่วนประกอบเสริมต่อภาษา ในHaskellสิ่งนี้ถูกจัดการโดยผ่านlibrary