21  Monadsในนิยามของโปรแกรมเมอร์ (Sketch)

โปรแกรมเมอร์ได้พัฒนาตำนานแทบทั้งหมดรอบๆmonad มันควรที่จะเป็นแนวคิดที่นามธรรมและยากที่สุดในการเขียนโปรแกรมเมอร์ ได้มีคนที่“เช้าใจมัน”และคนที่ไม่ สำหรับหลายคน ในตอนที่พวกเขาเข้าใจแนวคิดของmonadนั้นเหมือนกับประสบการณ์ที่เหมือนกับเวทมนตร์ monadได้ดึงส่วนประกอบสำคัญของการสร้างหลากหลายรูปแบบที่เราแค่ไม่มีการเปรียบเทียบที่ดีในทุกๆวันของเรา เราถูกลดไปยังการคลำหาในที่มืด เหมือนกับคมที่ตาบอดที่กำลังแตะส่วนต่างๆของช้างแล้วประกาศอย่างเต็มที่ว่า: “นั้นคือเชือก” “นั้นคือขอนไม้” “นั้นคือburrito”

ให้ผมได้บอกอย่างเป็นทางการ ความเป็นเวทมนตร์รอบๆmonadนั้นเป็นผลมาจากความเข้าใจผิด monadนั้นเป็นแนวคิดที่เรียบง่ายมาก มันคือความหลากหลายของการใช้งานของmonadที่มำให้เกิดความสับสน

ในฐานะส่วนของการค้นคว้าสำหรับpostนี้ ผมได้ค้นหาเทปพันท่อและการใช้งานของมันห นี้คือตัวอย่างเล็กๆของของที่คุณสามารถใช้งานมันได้

ในตอนนี้จินตนาการว่าคุณไม่รู้ว่าเทปพันท่อคืออะไรและคุณพยายามที่จะเข้าใจมันโดยอาศัย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)
        d += v[n] * v[n];
    return sqrt(d);
}

เทียบสิ่งนี้กับรูปแบบของHaskellที่มีstyleที่ทำให้การประกอบfunctionชัดเจนมากขึ้น

vlen = sqrt . sum . fmap (flip (^) 2)

(ในที่นี้ ในการทำให้สิ่งต่างๆนั้นลึกลับมากขึ้น ผมได้ใช้งานบางส่วนของ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ของในรูปแบบของ

a -> Writer w b

มันคือข้างในของการประกอบกันที่เราได้เขียนการรวมกันของ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(ลูกศรต่างๆสำหรับWriterfunctor)ก่อให้เกิดcategoryเพราะว่าWriterนั้นคือmonad

instance Monoid w => Monad (Writer w) where
    f >=> g = \a ->
        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 ()
tell s = Writer ((), s)

เราจะใช้มันภายหลังในฐานะตัวต่อสำหรับอีกfunctionแบบmonadต่างๆ

21.2 กายภาพของปลา

ในตอนการเขียนfish operatorสำหรับmonadที่แตกต่างคุณจับสังเกตว่าหลายๆโค้ดนั้นช้ำไปช้ำมาและสามารถถูกแยกออกมาอย่างง่ายดาย ในการเรื่ม การประกอบกันแบบKleisliของสองfunctionต้องreturn functionดังนั้นการเขียนของมันก็อาจจะเริ่มด้วยlambdaที่นำargumentของtypea

(>=>) :: (a -> m b) -> (b -> m c) -> (a -> m c)
f >=> g = \a -> ...

สิ่งเดียวที่เราสามารถทำได้กับargumentนี้คือการส่งมันไปยังf

f >=> g = \a -> let mb = f a
                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ใหม่ว่า

ma >>= f = join (fmap f 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
join (Writer ((Writer (a, w')), w)) = Writer (a, w `mappend` w')

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]
process = upCase >=> toWords

functionนั้เปลี่ยนทุกๆตัวอักษรในstring inputไปยังตัวพิมพ์ใหญ่และแยกมันไปยังคำต่างๆ และในขณะนั้นสร้างlogของการกะทำของมัน

ในเครื่องหมายของdoมันควรที่จะดูเหมือนสิ่งนี้

process s = do 
    upStr <- upCase s
    toWords upStr

ในที่นี้ upStrคือแค่Stringถึงแม้upCaseจะสรา้งWriterมา

upCase :: String -> Writer String String
upCase s = Writer (map toUpper s, "upCase ")

นั้นก็เพราะว่าส่วนของdoนั้นถูกแปลง(ลบการแต่งของsyntax)โดยcomplierไปยัง

process s = 
    upCase s >>= \upStr ->
        toWords upStr

ผลแบบmonadของupCaseนั้นผูกกับlambdaที่นำString มันคือชื่อของstringนี้ที่แสดงออกมาในส่วนของdoในตอนที่อ่านบรรทัดนี้

upStr <- upCase s

เราเรียกว่าupStrได้ผลลัพธ์ของupCase s

ในรูปแบบimparativeเทียมนั้นชัดเจนมากขึ้นในตอนที่เราinlinetoWords เราแทนที่มันกับการเรียงของไปยังtell ที่logของstring”toWords“ตามด้วยโดยการเรียกไปยังreturnกับผลลัพธ์ของการแยกของstringupStrโดยการใช้words สังเกตว่าwordsนั้นคือfunctionที่ทำงานบนstring

process s = do
    upStr <- upCase s
    tell "toWords "
    return (words upStr)

ในที่นี้ แต่ละบรรทัดในส่วนของdoนำแสนอbindที่อยู่ข้างในโค้ดที่ถูกแปลง(ลบการแต่งของsyntax)

process s =
    upCase s >>= \upStr ->
      tell "toWords " >>= \() ->
        return (words upStr)

สังเกตว่าtellสร้างค่าของunitดังนั้นมันไม่ต้องถูกส่งไปยังlambdaที่ตามมา โดยไม่สนใจเนื้อหาของผลลัพธ์แบบmonad (แต่ไม่ผลของมัน ในที่นี้คือการให้ไปยังlog)นั้นค่อนข้างทั่วไป ดังนั้นได้มีoperatorพิเศษในการแทนที่bindในกรณีนี้

(>>) :: m a -> m b -> m b
m >> k = m >>= (\_ -> k)

การแปลง(ลบการแต่งของsyntax)จริงๆของโค้ดของเราดูเหมือนแบบนี้

process s =
  upCase s >>= \upStr ->
    tell "toWords " >>
      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


  1. https://bartoszmilewski.com/2014/02/26/c17-i-see-a-monad-in-your-future/↩︎

  2. https://bartoszmilewski.com/2014/04/21/getting-lazy-with-c/↩︎