פרוייקט "התלמיד והמחשב", בעיות 16-20 - רובי
אני רוצה להתעסק הפעם עם סדרה די ארוכה של תרגילים בבת אחת, כי כולם זהים ברוחם: קראו כמות נתונים גדולה כלשהי לאיזה ייצוג פנימי שלכם, ובצעו מניפולציות על הנתונים הללו. בעיות כאלו הן הלחם והחמאה של שפות סקריפטינג - רבות מהן נולדו מלכתחילה כדי לטפל בבעיות מסוג זה. בפרט Perl, שהיא כנראה שפת הסקריפטינג הידועה ביותר וההשפעות שלה על רובי חזקות מאוד (גילוי נאות: אני שונא, מתעב ממש, את Perl. לדעתי זו שפה שמעודדת כתיבה של קוד דוחה ובלתי קריא. אם הייתי רוצה להיות ממש טרול הייתי אומר שהיא גם מעודדת את המתכנת להתלהב מעצמו יתר על המידה).
התרגילים הם כדלהלן:
- בעיה 16: לקרוא רשימת ציונים ולהדפיס את הממוצע שלה.
- בעיה 17: לקלוט סדרת מספרים ולהדפיס אותה בסדר הפוך
- בעיה 18: לקלוט רשימה של מספרים ולהדפיס את המינימלי והמקסימלי מביניהם
- בעיה 19: לקלוט רשימת ציונים ולהדפיס כמה מהציונים הם בין 40 ל-60
- בעיה 20: לקלוט רשימה של שמות תלמידים ולהדפיס את השמות שמתחילים ב-P
אלו הבעיות האחרונות בחלק הראשון של הספר.
אני הולך לשלב את כל הבעיות הללו לתרגיל אחד גדול: נתון קובץ טקסט בשם grades.txt שמכיל שמות של תלמידים והציונים שהם קיבלו במבחן; צריך לקרוא אותו ואז לבצע מניפולציות על המידע שנקרא מתוכו: להדפיס את הציון הממוצע של התלמידים; להדפיס את רשימת הציונים (בלי שמות) בסדר הפוך; להדפיס שם של תלמיד שקיבל ציון מקסימלי (ומה הציון) וכך גם עבור תלמיד שקיבל ציון מינימלי; להדפיס כמה מהתלמידים קיבלו ציון בין 40 ו-60; ולהדפיס את שמות כל התלמידים ששם משפחתם מתחיל ב-P (לא שמם הפרטי). בפוסט הזה אפתור את הכל רק ברובי ואדחה את הפתרונות בשפות האחרות לפוסט מאוחר יותר, כי הפוסט יהיה ארוך מספיק גם כך. גם אם הפתרונות ברובי לא מעניינים אתכם קראו לפחות את הדיון שאני מציג עכשיו בתחילת הפוסט, שהוא כללי ויהיה רלוונטי גם בשפות האחרות.
השאלה הראשונה שצריך לענות עליה היא איך המידע כתוב בתוך grades.txt עצמו. הדרך המתבקשת ביותר היא לכתוב קובץ שבו כתוב שם של תלמיד, ואז רווח, ואז הציון שלו. אבל שימו לב שאני רוצה לדעת להבדיל בין שם פרטי של תלמיד ובין שם המשפחה שלו. למה זו בעיה? כי הנה שני שמות: מצד אחד, חורחה לואיס בורחס ומצד שני אורסולה לה גווין. אצל בורחס, “חורחה לואיס” הוא השם הפרטי ו”בורחס” הוא שם המשפחה (השם האמיתי של בורחס מסובך יותר אבל נעזוב את זה) ואצל לה גווין “אורסולה” הוא השם הפרטי ואילו “לה גווין” הוא שם המשפחה. ואיך המחשב יוכל להבחין? סתם רווח לא מספיק. צריך להתייחס לשם פרטי ושם משפחה כאל שדות מידע שונים, ולהבדיל בין שדות מידע שונים עם תו שאינו רווח (ואז אותו תו לא יוכל להיכלל בתוך שדה מידע בלי התחכמויות נוספות).
זה מוביל אותנו לפורמט נפוץ ביותר של קבצי מידע שנקרא CSV, שפירושו הסטנדרטי הוא Comma-Separated Values (“ערכים המופרדים בפסיק”). קובץ CSV הוא פשוט קובץ טקסט שבו כל שורה מייצגת רשומת מידע כלשהי המורכבת משדות, כשבין כל שני שדות יש פסיק. אלא שלא כולם אוהבים להשתמש בפסיק דווקא, וכדי שיהיה מעניין וטיפה לא טריוויאלי אני אשתמש דווקא בתו הטאב כדי להפריד בין שדות. טאב הוא המקש הזה שברוב המקלדות נמצא ליד Q ובתוך עורך טקסט בדרך כלל מייצר מין “קפיצה” של כמה רווחים במקום רווח אחד - לא אכנס לדקויות המלאות שלו כרגע.
אז ישבתי ובניתי קובץ ציונים עם שמות של דמויות מסדרת “שיר של אש ושל קרח”:
האתגר הראשון הוא לקרוא אותו לתוך מבנה נתונים כלשהו של רובי. במקרה שלנו מתבקש לתחזק מערך של רשומות. אבל מהי רשומה? אפשר לחשוב גם עליה בתור מערך, כמובן, אבל כאן לכל תא במערך יש משמעות מילולית די ברור - “שם פרטי”, “שם משפחה”, “ציון”. זה יותר אינפורמטיבי מאשר “תא 0, תא 1, תא 2” שבו משתמשים כדי לתאר תאים במערך, ולכן מזמין שימוש במבנה נתונים אחר שיש ברובי - Hash. מבנה הנתונים הזה נקרא לפעמים, בשפות אחרות, גם “מילון”, או “מערך אסוציאטיבי”. מבחינה מעשית הוא דומה למערך, פרט לכך שבמקום אינדקסים שהם מספרים, יש לו אינדקסים שיכולים להיות הרבה מאוד סוגים אחרים של מידע; בהקשר של Hash לאינדקסים הללו לא קוראים “אינדקסים” אלא “מפתחות”. הגמישות של Hash בנוגע למי יכול לתפקד כמפתח אצלו היא מאוד גבוהה, אבל לא אכנס כרגע לעובי הקורה הזה.
דרך סטנדרטית ליצור Hash עם ערכים קיימים מראש היא לכתוב סוגריים מסולסלים ובתוכם לכתוב זוגות של מפתח ואז הערך שהמפתח מצביע עליו בתוך ה-Hash, כשכל זוג כזה מופרד בפסיקים מיתר הזוגות, ובין המפתח לערך שהוא מצביע עליו מצויר חץ. כלומר, ה-Hash שמתאים לשורה הראשונה בקובץ שלעיל הוא זה:
זה Hash לגיטימי לכל דבר, אבל לא תראו יותר מדי ממנו ברובי בגלל השימוש במחרוזות בתור מפתחות. זה לא שאסור להשתמש במחרוזות בתור מפתחות, פשוט יש משהו יותר טוב שמשתמשים בו והגיע הזמן להציג - סימבולים (Symbols). סימבולים הם משהו יחסית ייחודי לרובי, בגלל שהגישה של רובי למחרוזות היא טיפה שונה מאשר זו של שפות אחרות. ההסבר הקצר, למי שבקיאים בפרטים הטכניים: ברובי מחרוזת הן תמיד mutable ואפשר לחשוב על סימבול בתור מחרוזת שהיא immutable ולכן השימוש בהן חוסך זמן ומקום. הנה ההסבר הארוך, למי שלא בקיאים:
מחרוזת, באופן כללי, היא סדרה של תווים שמאוחסנת אי שם בתוך הזכרון שהתוכנית משתמשת בו. שפת התכנות יכולה לנקוט באחת משלוש גישות: או שמחרוזות יהיו ניתנות לשינוי, כלומר יהיה ניתן לגשת לאיזור בזכרון שבו התווים של המחרוזת מאוחסנים ולשנות שם ערכים ולקבל מחרוזת עם ערך חדש; במקרה הזה אומרים שהמחרוזות הן mutable; או שהמחרוזות יהיו בלתי ניתנות לשינוי, או שהן יהיו גם וגם - חלק יהיה ניתן לשנות וחלק לא. בג’אווהסקריפט, למשל, אי אפשר לשנות מחרוזות, בכלל (פונקציות ש”משנות” מחרוזות בעצם מחזירות כפלט את המחרוזות-לאחר-השינוי ולא משנו את הקלט); ב-C לעומת זאת יש כאלו שהן const ואי אפשר לשנות אותן, ואחרות שהן לא const ואפשר לשנות אותן. ברובי אפשר לשנות את כל המחרוזות, ולכן יש טיפוס נתונים אחר, שנקרא סימבול, שמזכיר מחרוזת אבל הוא לא ניתן לשינוי.
מה היתרון במחרוזות שאינן ניתנות לשינוי? היתרון הגדול הוא שאם כמה משתנים מכילים את אותה מחרוזת אז אין צורך לאחסן את אותה המחרוזת כמה פעמים; מספיק להקצות לה מקום אחסון יחיד בזכרון ושכל המשתנים שמכילים מחרוזות יצביעו לאותו מקום בזכרון. כך גם חוסכים את הזכרון שהמחרוזת דורשת, וגם קל יותר להשוות מחרוזות - בהינתן שתי מחרוזות פשוט בודקים אם שתיהן מצביעות לאותו מקום. כשמשתמשים במחרוזות בתור אינדקס, מה שאומר שהן יהיו מעורבות בהרבה השוואות, הדבר גורם כמובן לשיפור משמעותי בביצועים (למעשה, אני משקר כאן בגסות כי אני מסתיר פרטי מימוש של Hash שאדבר עליהם בעתיד - אבל העיקרון עדיין נכון).
בקיצור, בהקשר של מפתחות של Hash, או כל הקשר אחר של מחרוזת שאנחנו לא באמת נרצה לבצע בה שינויים, עדיף להשתמש בסימבול במקום במחרוזת. איך כותבים סימבול? כמו שכותבים מחרוזת, אבל עם נקודותיים בהתחלה; ובמקרה שבו המחרוזת היא משהו שיכול להיות שם חוקי של משתנה או פונקציה (הכללים שקובעים את זה הם לא טריוויאליים - למשל, בהמשך נראה שהסימן + הוא חוקי מהבחינה הזו - אבל כלל אצבע טוב בשבילכם הוא משהו שמכיל רק אותיות, מספרים וקו תחתון ולא מתחיל במספר), אפשר גם להסיר את המרכאות לגמרי. הנה כמה דוגמאות לסימבולים:
שמתי כמה סימבולים מאוד מוזרים שם - בדרך כלל סימבולים נראים כמו זה של first_name, בלי התחכמויות מיותרות.
עכשיו אפשר לומר איך הרשומה של השורה הראשונה בקובץ אמורה להיראות ברובי:
שימו לב שאת הערכים של השמות אני כן שומר כמחרוזות ולא כסימבולים, פשוט כי במקרה שלהם זה הרבה פחות חשוב (מה שכדאי שיהיה סימבול הוא המפתחות). עוד סיבה שבגללה לא כדאי לשמור את הערכים הללו כסימבולים הוא שמרגע שסימבול נוצר, הוא הולך להמשיך להתקיים לאורך כל חיי התוכנית (להבדיל ממחרוזת, שברגע שהתוכנית מפסיקה להשתמש בה - ויש לרובי דרך לגלות את זה - היא הופכת ל”זומבי” שעלול להיות מושמד כל רגע כדי לפנות מקום כחלק מתהליך שנקרא “איסוף אשפה”).
החל מגרסה 1.9.1 של רובי יש גם תחביר נוסף שבו אפשר להשתמש כשמגדירים Hash שנראה קצת “נקי” יותר:
יש כאן מוסכמה שצריך להיות מודעים אליה - אם בתוך הגדרה של Hash יש לנו מפתח שנראה כמו משתנה אבל אחריו במקום החץ הרגיל שיש ב-Hash מופיעות נקודותיים, אז המפתח הזה הוא בעצם סימבול. אי אפשר לעשות תעלול כמו לכתוב מחרוזת במרכאות ואחריה נקודותיים; התעלול הזה עובד רק עם סימבולים שמלכתחילה אפשר היה לכתוב בלי מרכאות. המון אנשים מתעבים את התחביר החדש הזה, אבל אני די מחבב אותו.
כל ההקדמה הזו לא הסבירה לנו איך אפשר לקחת קובץ CSV ולקבל ממנו מערך של Hash-ים שמתאימים לשורות שלו. אז הנה:
בשורה הראשונה אני פותח את הקובץ לקריאה בלבד (זו המשמעות של ה-r), קורא את כולו בבת אחת ואז מפצל על פי שורות. התוצאה היא מערך של מחרוזות, מחרוזת לכל שורה, ששמור ב-lines. בשורה הבאה אני מתחיל תהליך שבו לוקחים כל שורה line בתוך lines, ובונים ממנה את הרשומה המתאימה: קודם כל לוקחים את השורה ומפרסרים אותה על ידי כך שמפרקים את השורה למערך של מחרוזות כשהפירוק הוא בדיוק בין תווי הטאב (t). בשורה הבאה אני בונה את ה-Hash מתוך השורה המפורסרת, ועל הדרך גם ממיר את הציון ממחרוזת למספר.
התהליך הזה הוא לא מסובך, כמובן, אבל קצת מייגע לכתוב את כל הטקסט הזה בכל פעם שבה רוצים לפרסר קובץ CSV - הרי המקום היחיד שהוא לא גנרי אלא באמת יש בו התייחסות לתוכן ה-CSV הספציפי הוא השורה שבה מייצרים את ה-Hash. אז למה שלא תהיה ספריה סטנדרטית שעושה את זה? ובכן, באמת יש ספריה סטנדרטית לטיפול בקבצי CSV (גם קריאה וגם כתיבה שלהם וגם עוד דברים). יחד איתה אפשר להחליף את הקוד שלעיל בשורה אחת:
השורה הזו תמימה למראה ודי ברור מה אני עושה בה - אני משתמש בפונקציה read של הספריה CSV שלוקחת קובץ שהשם שלו נתון בתור הפרמטר הראשון, ואת סוג התו המפריד בתור פרמטר שני (פרמטר שאפשר לוותר עליו אם התו המפריד הוא אכן פסיק) ומחזירה מערך-של-מערכים כשכל מערך פנימי כזה הוא שורה אחת של הקובץ אחרי פרסור. את זה אני מעביר ל-collect שממיר את המערכים הפנימיים ב-Hash וחסל. אבל האמת היא שבשורה הזו מסתתר קסם מאוד לא טריוויאלי שיש ברובי ונוגע להעברת פרמטרים לפונקציות: ה-col_sep הזה שמופיע שם. זה נראה כאילו אני לא סתם מעביר פרמטר, אלא מעביר פרמטר עם שם. מה בעצם קורה פה?
הנה עוד מוסכמה שפועלת מאחורי הקלעים ברובי: כשמעבירים פרמטרים לפונקציה, אם הפרמטר האחרון הוא Hash, אפשר לכתוב אותו בלי הסוגריים המסולסלים. הנה דוגמא:
שתי הקריאות הראשונות ל-f ידפיסו בדיוק את אותו הדבר; לעומת זאת, הקריאה השלישית פשוט תיכשל ורובי תגיד שהיא ציפתה לקבל שני פרמטרים אבל קיבלה רק אחד.
שיטת הכתיבה הזו נראית קצת מוזרה ממבט ראשון, אבל היא נפוצה מאוד בשימושים של רובי בעולם האמיתי (ובראשם Rails) ולכן חשוב להכיר אותה. גם ב-CSV זה המצב (בגרסת רובי 1.9.1; בגרסאות ישנות יותר זה היה שונה): פקודת read מקבלת שני פרמטרים שהראשון מביניהם הוא שם הקובץ שצריך לפעול עליו, והשני הוא Hash (עם ערך דיפולטי של Hash ריק, כך שהוא יכול לא להיות מועבר כלל) של כל מני אופציות שמשפיעות על אופן הפרסור. למשל, יש גם פרמטר של row_sep שבורר את הסימן המפריד בין השורות.
למה נוקטים בדרך העברת פרמטרים שכזו? פשוט, כי יש הרבה מאוד אפשרויות שאפשר להעביר ל-read, אבל ברוב המקרים המשתמש לא יצטרך את כולן. ברור לגמרי שאי אפשר להעביר אותן בתור פרמטרים בצורה הרגילה, כי זה יכריח את המשתמש לכתוב ערכים לכל האפשרויות (שימוש בערכי ברירת מחדל הוא בעייתי כי מה אם האפשרות שהמשתמש רוצה לשנות היא אחרונה?) וזה יצריך אותו לבדוק מה הסדר הנכון של הפרמטרים בחתימה של הפונקציה שהוא מפעיל, וזה יהיה סיוט אחד גדול. לכן צריך להעביר את כל האפשרויות כשהן מקובצות למשתנה יחיד שלא חייב להכיל את כולן במפורש, ו-Hash הוא הבחירה המתאימה לשם כך. התחביר החדש של כתיבת Hash-ים גורם לקריאה הזו להיראות נחמד; בגרסאות ישנות יותר של רובי היינו צריכים לכתוב
שגם הוא לא נורא בכלל אבל נראה קצת פחות טוב לטעמי.
עכשיו אפשר להציג סוף סוף את הקוד שפותר את התרגיל במלואו:
כאשר מפעילים את הקוד הזה על קובץ הטקסט שהצגתי קודם, הפלט הוא:
בתוך הקוד הכנסתי כמה התחכמויות חדשות, אז בואו נעבור עליו שורה שורה כדי להבין מה הולך שם.
שורות 1-2 מוקדשות לקריאת קובץ ה-CSV - בעצם, הן מסכמות את כל הדיון עד כה. בשורה 4 אני “גוזר” מתוך המידע הכללי מערך שכולל רק את הציונים ותו לא - זה שימוש סטנדרטי ב-collect.
שורה 5 היא מעניינת בגלל האופן שבו אני קורא ל-inject כדי לסכום את הציונים. בצורת הקריאה המלאה שלו, inject שעושה את זה ייראה ככה:
בצורת הקריאה הזו, inject מקבל שני פרמטרים - פרמטר אחד שהוא “ערך התחלתי” ופרמטר שני שהוא בלוק. הוא מאתחל את sum להיות הערך ההתחלתי, ואז מריץ סדרתית את הבלוק כאשר הוא מעביר לו את sum ובתור x מעביר לו את אברי המערך, ואחרי כל הרצה של הבלוק הוא מעדכן את sum להיות הערך שהבלוק החזיר.
לעומת זאת, בתוך הקוד הנוכחי לא העברתי בלוק. רובי יודעת לזהות את זה, ולכן היא גם יודעת למה לצפות - הוא מצפה שאעביר לו פרמטר שהוא סימבול, שמתאר את שם הפונקציה שאני רוצה שהוא יפעיל. אפשר לפני הסימבול להעביר לו ערך התחלתי, אבל אם לא עושים את זה הוא פשוט משתמש בערך של האיבר הראשון במערך בתור הערך ההתחלתי.
ייתכן מאוד שאתם אומרים עכשיו “אבל רגע! זה לא מספיק להעביר את סימן הפלוס! הרי אותו הסימן מייצג פונקציות שונות - למשל, חיבור של מספרים וחיבור של מערכים ואלו שני דברים שונים!” וזה כמובן נכון, אבל רובי יודעת להתמודד עם זה - הטיפוס של sum יקבע מה תהיה הפונקציה שתופעל (הסבר יותר מקיף לעניינים הללו אוכל לתת אחרי שאסביר סוף סוף מה זה class).
הקטע הזה, של להעביר מחרוזת שהיא שם של פונקציה שרוצים להפעיל, ועוד כשמדובר על פונקציה עם שם “מוזר” כמו +, הוא די חריג בשפות תכנות, ובהחלט מהווה את אחת מהסיבות שרובי כל כך מגניבה לטעמי. אבל אין צורך להתעמק בפרטים כרגע - בינתיים אפשר לחשוב על זה בתור דרך מקוצרת לבצע סכום ותו לא.
בשורות 6-7 אני מנסה להשיג את הרשומות של תלמידים עם ציון גדול וקטן ביותר, בהתאמה. שימו לב - כל הרשומה, לא רק הציון. לכן סתם להשתמש בפונקציה max לא הולך לעבוד - לא ברור לרובי איך להשוות שתי רשומות. לכן אני מצרף ל-max בלוק, שמקבל כקלט שני איברים שיכולים להופיע ברשימה - a,b - ומטרתו היא להשוות ביניהם. הוא צריך להחזיר ערך שקטן מ-0 אם a בא לפני b; ערך שגדול מ-0 אם b בא לפני a; וערך ששווה ל-0 אם a שווה ל-b או שאנחנו אדישים בנוגע לסדר ביניהם. בשביל לעשות את זה, אני ראשית “מחלץ” את הציון מתוך שתי הרשומות הללו, ואז משווה את שני המספרים שקיבלתי עם אופרטור מיוחד של רובי, שמסומן <=>, ומבצע בדיוק השוואה מהסוג שתיארתי ומחזיר 1, 0 או מינוס 1. האופרטור הזה הוא עוד פיסת סינטקס שאני מאוד מחבב ברובי.
בשורה 7 אני מתחכם - במקום להשתמש ב-min ולהעביר את אותו הבלוק כמו בשורה 6, אני משתמש שוב ב-max ומשנה משהו בבלוק - האם תוכלו להבין מה ולמה זה עושה את מה שזה צריך לעשות? (ואתגר נוסף - האם יש דרך לתת קלט לסקריפט שלי כך שהשימוש הזה ב-max לא יחזיר את אותו פלט כמו השימוש ב-min עם הבלוק של שורה 6?)
בשורה 8 אני משתמש ב-find_all כדי למצוא את כל רשומות התלמידים ששם משפחתם מתחיל ב-P. אבל איך אני בודק ששם מתחיל ב-P? כאן שוב יש התחכמות חדשה: אני לוקח את המחרוזת של השם ומפעיל עליה את האופרטור ~=. האופרטור הזה מקבל בצד ימין ביטוי רגולרי. לא אסביר עכשיו מה זה בדיוק ביטוי רגולרי כי זה דבר שדורש פוסט בפני עצמו, אבל בקצרה: ביטוי רגולרי מתאר תבנית כלשהי שמילים יכולות להתאים או לא להתאים לה. הביטוי הרגולרי הספציפי שכתבתי אומר בדיוק “מילים שהאות הראשונה בהן היא P (ה-^ שמופיע שם פירושו “תחילת המילה”).
כל יתר הקוד הוא הדפסות. בשורה 11 אני הופך את הרשימה בעזרת פקודת reverse ואחר כך מפעיל עליה את פקודת inspect שמשמשת להמרת מערך למחרוזת יפה, עם סוגריים מרובעים שתוחמים את המערך, ומדפיס את זה. אני מקווה שיתר הקוד ברור.
זה מסיים עם הבעיות הללו ואני מקווה שנותן טעימה קטנה מהכוח האמיתי של רובי, שעד כה לא ממש הוצג; בהמשך אנצל את התרגילים הקלים של חלק ב’ כדי להתחיל להציג באופן מסודר חלק מהרעיונות שהראיתי כאן ברמז.
נהניתם? התעניינתם? אם תרצו, אתם מוזמנים לתת טיפ: