מספרי צ'רץ'
גרסה באנגלית לפוסט כאן.
בואו נדבר על מספרים. אבל לא על המספרים ה"רגילים" שכולנו מכירים, אלא על יצוג אחר של מספרים, מספרים בקידוד צ'רץ'.
קידוד צ'רץ' היא דרך להציג את המספרים הטבעיים בתור פונקציות מסדר גבוה.
פונקציה מסדר גבוה היא פונקציה שמקבלת פונקציה כלשהי כקלט, או מחזירה פונקציה כפלט. למעשה, אלה פונקציות שפועלות על פונקציות.
אחרי שהבנו מה זו פונקציה מסדר גבוה נגדיר את אופן הקידוד:
הקידוד יתאים למספר הטבעי את הפונקציה שמוגדרת כך:
מקבלת שני פרמטרים. הראשון הוא פונקציה שמסומנת ב- וממפה איבר כלשהו מסוג אל איבר כלשהו מסוג . הפרמטר השני הוא איבר כלשהו ב- שנסמן ב-.
הפונקציה תחזיר את התוצאה של הפעל ת על , פעמים בשרשרת. כלומר, פעם ראשונה נפעיל את על , בפעם השנייה נפעיל את על התוצאה של השלב הקודם (כלומר ), וכך האלה פעמים.
אם נרצה לכתוב את זה באופן פורמלי נכתוב את זה כך:
נסתכל על כמה דוגמאות בשביל להפוך את זה לפחות מופשט:
נגדיר את כך: . ולכן בדוגמאות הבאות, הקבוצה שהזכרנו למעלה, , תהיה המספרים הממשיים ().
- , זו הפונקציה שמותאמת למספר . התוצאה של תהיה . למה? כי הפעלנו את הפונקציה על אפס פעמים, כלומר, לא הפעלנו, ולכן נשארו עם האיבר המקורי. למעשה, באופן כללי הפונקציה מחזירה את הפרמטר השני שלה כמו שהוא.
- התוצאה של היא
- התוצאה של היא
אז הבנו איך הקידוד ממפה מספר טבעי לפונקציה. זה רק כיוון אחד. המיפוי ההפוך יותר פשוט. אם יש לנו פונקציה שידוע לנו שהיא מקודדת מספר טבעי בקידוד צ'רץ', אבל אנחנו רוצים לגלות מהו אותו ה-, נחשב את הפונקציה עם בתור פרמטר ראשון ו- בתור פרמטר שני. למה זה עובד? כי תפעיל את על פעמים, כלומר, תוסיף ל- את המספר , ולכן התוצאה תהיה .
אז המיפוי עובד לשני הכיוונים 🥳.
בואו נסמן את הפונקציה שמתאימה למספר את הפונקציה ב-. את הפונקציה שעושה את המיפוי ההפוך נסמן ב-.
עכשיו נגדיר פעולות חיבור וכפל בין מספרי צ'רץ' שישמרו על המיפוי. כלומר, נרצה שיתקיים:
הסימון פה טיפה טריקי.
מה שקורה מצד שמאל זה חיבור רגיל בין שני טבעיים, ו-. על תוצאת החיבור מפעילים את ומקבלים את הפונקציה .
מה שקורה מצד ימין זה שמפעילים את על כל מספר טבעי בנפרד, מקבלים שתי פונקציות, ו- ומבצעים בינהן חיבור. זה לא חיבור בין מספרים, אלא בין פונקציות. ואת החיבור הזה, אנחנו נגדיר עכשיו.
למעשה, התוצאה של החיבור צריכה להיות הפונקציה , כלומר, היא מקבלת כפרמטרים פונקציה וערך התחלתי ומפעילה את על סה"כ פעמים.
באותה הצורה נגדיר כפל. התוצאה של היא הפונקציה .
בעיניי זו דרך מהממת לייצג מספרים טבעיים. מה שאלונזו צ'רץ' רצה להראות במיפוי הזה, זה שאפשר לפתור כל בעיה חישובית ע"י שימוש רק בפונקציות בתור טיפוס נתונים בסיסי.
כמובן שלא משתמשים באופן היצוג הזה בפרקטיקה, כי הרבה יותר פשוט וזול לייצג בזכרון מספר ביצוג בינארי מאשר בתור פונקציה.
אבלללל, זה לא ימנע מאיתנו לעשות את זה בכל זאת, כי זה יעזור לנו להבין איך בפועל עובדים חיבור וכפל בקידוד הזה.
אנחנו הולכים להגדיר את הקידוד שלנו ב-Haskell, שזו שפה נהדרת למטרה, כי פונקציות הן טיפוס בסיסי אצלה, וקל מאוד לכתוב פונקציות שמטפלות בפונקציות.
נגדיר טיפוס נתונים חדש בשם CNumber
. למעשה, הוא לא טיפוס חדש, אלא מעטפת לטיפוס:
פונקציה שמקבלת כפרמטרים: פונקציה שממפה איבר מ- לאיבר ב- כפרמטר ראשון ואיבר ב- כפרמטר שני. ומחזירה איבר ב-.
ה- הזה יכול להיות כל טיפוס נתונים. כלומר, הוא פרמטר גנרי בפונקציה ש-CNumber
עוטף.
נכתוב את זה ב הסקלית:
1{-# LANGUAGE RankNTypes #-}2newtype CNumber = Nr (forall t. (t -> t) -> t -> t)
השורה הראשונה מאפשרת את השימוש במילה forall
. בשורה השנייה אנחנו מגדירים את CNumber
ואומרים שהבנאי שלו נקרא Nr
והוא מקבל פונקציה גנרית (שהמשתנה הגנרי שלה הוא t
) מ הצורה שהגדרנו מקודם.
אני אזכיר שבהסקל הטיפוס a -> b -> c
מתאר פונקציה שמקבלת כפרמטר ראשון איבר מסוג a
וכפרמטר שני איבר מסוג b
ומחזירה איבר מסוג c
.
אז מה שהגדרנו כאן זה תבנית לפונקציות . שימו לב שזה לא אומר שכל הפונקציות שמתאימות לתבנית בהכרח מתארות מספרים טבעיים בקידוד צ'רץ', התבנית היא באופן כללי לפונקציות שמקבלות פונקציות ואיבר ומחזירות איבר.
בואו נשתמש בהגדרה בשביל להגדיר את , ו- החדשים:
1zero = Nr (\ f x -> x )2one = Nr (\ f x -> f x )3two = Nr (\ f x -> f (f x) )
כמו שאמרנו מקודם, לא מפעילה את בכלל ומחזירה את כמו שהוא. מפעילה את על פעם אחת. ובאותו האופן, מפעילה פעמיים.
נניח שיש לנו עצם מסוג CNumber
, וידוע שהוא קידוד של מפר טבעי כלשהו. איך נוכל לשחזר את המספר שהוא מייצג? כבר הזכרנו את זה למעלה, ובהסקל נכתוב את זה ככה:
1eval :: CNumber -> Int2eval (Nr n) = n (+1) 0
השתמשנו בפונקציה עם הפרמטרים (+1)
שמקבילה ל- מלמעלה ו-0
, וכך קיבלנו את המספר הטבעי שהפונקציה מייצגת.
לפני שנממש את פונקציית החיבור, נממש אחת פשוטה יותר בשם succ
. מה שהיא עושה זה לקבל עצם מסוג CNumber
, ולהחזיר את העצם הבא אחריו ביצוג. כלומר, אם היא קיבלה את one
היא תחזיר את two
. הפונקציה הזו מקבילה לפונקצית העוקב של המספרים הטבעיים.
1succ :: CNumber -> CNumber2succ (Nr a) = Nr (\ f x -> f (a f x) )
נניח שהפונקציה succ
מקבלת CNumber
שמתאר את הטבעי .
אנחנו מגדירים כתוצאה פונקצייה חדשה שמקבלת פונקציה f
ופרמטר x
.
היא תשתמש ב-a
בשביל להפעיל את f
על x
פעמים, ואז תפעיל עוד פעם את f
על התוצאה הסופית. וכך בעצם קיבלנו את העצם מסוג CNumber
שמתאר את .
הערה קטנה: בשביל שהדבר הזה יתקמפל, חשוב להחליף את import Prelude
שמופיע בראש הקוד, ב-import Prelude hiding (succ)
, כדי שהפונקציה המובנית succ
לא תתנגש עם המימוש שלנו.
עכשיו יהיה לנו הרבה יותר קל להגדיר את פונקציית החיבור. אנחנו רוצים פונקציה בשם add
שתקבל שני עצמים מסוג CNumber
ותחזיר את ה-CNumber
שמתאר את החיבור של העצמים, לפי החיבור שהגדרנו למעלה.
1add :: CNumber -> CNumber -> CNumber2add (Nr a) (Nr b) = Nr (\ f x -> a f (b f x))
נניח ש-a
ו-b
מייצגים את n
ו-m
בהתאמה. הפונקציה שכתבנו מצד ימין משתמשת קודם ב-b
בשביל להפעיל את f
על x
פעמים, ואז משתמשת ב-a
בשביל להפעיל על התוצאה את f
עוד פעמים. סה"כ היא מפעילה את f
על x
פעמים, שזה בדיוק מה שרצינו.
אבל אנחנו מסוגלים לכתוב את זה יותר יפה. יש לנו את הפונקציה a
שיודעת להפעיל פונקציות על איבר כלשהו פעמים. למה לא שנשתמש בה בשביל להפעיל את succ
על Nr b
וככה למצוא את ה-CNumber
שמתאר את .
למה זה הגיוני? כי אם מפעילים את succ
על b
פעם אחת מקבלים את ה-CNumber
שמייצג את .
ולכן אם נפעיל את succ
על b
פעמים באמצעות a
נקבל את ה-CNumber
שמייצג את .
1add :: CNumber -> CNumber -> CNumber2add (Nr a) (Nr b) = a succ (Nr b)
יש דרך אפילו יותר קצרה לכתוב את זה, והיא:
1add :: CNumber -> CNumber -> CNumber2add (Nr a) = a succ
אבל אנחנו לא מנסים להגיע לקצרנות, אלא לקריאות 😊
נשאר לנו לממש רק פונקציית הכפל! אציין שזה תרגיל נהדר בעיניי וכדאי לכם לנסות לפתור אותו בעצמכם. כמובן שאתם יכולים פשוט להסתכל על התשובה בהמשך, אבל יהרס כל הכיף 🙃
אנחנו רוצים להשתמש עוד פעם ב-a
בשביל לחשב את ה-CNumber
החדש. נעשה את זה ככה:
1mult :: CNumber -> CNumber -> CNumber2mult (Nr a) (Nr b) = a (add (Nr b)) zero
נעבור שלב שלב. התוצאה add (Nr b)
היא פונקציה שמקבלת CNumber
כלשהו ומחברת אליו את Nr b
. אנחנו משתמשים ב-a
בשביל לחבר ל-zero
, ה-CNumber
שמייצג את , את b
פעמים. בסה"כ הפעלנו על zero
את succ
פעמים בכל הפעלה של add (Nr b)
וסה"כ פעמים. ולכן קיבלנו את ה-CNumber
שמייצג את .
באותה צורה אפשר להגדיר חזקה! אבל אותה אני לא אסביר, אלא פשוט אראה את הקוד:
1pow :: CNumber -> CNumber -> CNumber2pow (Nr a) (Nr b) = b (mult a) one
אפשר להמשיך עם זה גם לטטרציה אבל נראה לי שהקונספט הובן 😊