פרוייקט "התלמיד והמחשב", בעיה 15

בעיה 15 בספר מעמידה אותי בפני אתגר מסוג שונה לגמרי מאלו שהיו עד כה, ואתגר שהוא בכלל לא מתמטי באופיו. המטרה היא כזו: לכתוב קוד שכשהוא מורץ גורם לאנימציה של טקסט ש”רץ” על המסך מצד שמאל לימין. “כמו בשלט פרסומת” בלשונו של הכותב (ראיתם שלט פרסומת כזה לאחרונה, בעצם?)

ולמה זו בעיה? כי בואו נחשוב לרגע על האופן שבו תוכניות רובי הוציאו פלט עד כה; השתמשתי בפקודה puts שמדפיסה למסך שורה. אבל הפקודה הזו לא מאפשרת לי לצייר אנימציית טקסט - אם אני אחזור עליה הרבה פעמים, פשוט יודפסו למסך הרבה שורות. כל הרעיון באנימציה הוא שתמונות שונות מצוירות אחת אחרי השניה באותו המקום; אם אפשר יהיה לראות את התמונות הקודמות מעל התמונה הנוכחית זה יקלקל את כל הרושם. אז אני צריך להשתמש בפקודה אחרת, שלא מעבירה שורה. ברובי הפקודה הזו נקראת print, ולמעשה ברוב השפות זו פקודת ההדפסה הנפוצה וכדי לעבור שורה צריך להגיד זאת במפורש, על ידי הוספת סימן עבור ירידת שורה בסוף המחרוזת שמדפיסים.

אבל רגע, מה זה בכלל סימן ירידת שורה? הרי אי אפשר ללחוץ אנטר בתוך מחרוזת, נכון? (למען האמת - ברובי אפשר, אם המחרוזת מיוצגת בצורה מסויימת, אבל נעזוב את זה).

כדי לכתוב סימנים “מיוחדים” בתוך מחרוזת כמו ירידת שורה, טאב וכדומה כותבים אותם באופן עקיף: קודם כל כותבים לוכסן, הסימן \, ואחריו כותבים אות שמתאימה לסימן שרוצים לכתוב. כך למשל טאב הוא t\ ואילו ירידת שורה היא n:

print "Hello world!\n"

השורה הזו תדפיס “Hello world!” ותרד שורה בסוף. אותו הדבר בדיוק כמו לכתוב puts בלי ה-n\ בסוף. אלא שמה שקורה מאחורי הקלעים הוא קצת יותר מלוכלך, למרבה הצער, והסיבה לכך היא שהסימן לירידת שורה הוא שונה במערכות הפעלה שונות.

במערכות הפעלה מבוססות UNIX, ובפרט בלינוקס, ירידת שורה היא פשוט n. לעומת זאת, בוינדוס ירידת שורה מתוארת על ידי שני סימנים שבאים ברצף: r\n. מבחינת כותב הקוד הדבר הזה שקוף; מספיק לכתוב n\ בתור סימון לסוף השורה, ורובי כבר תוציא את הסימנים הנכונים כתלות באיזו מערכת הפעלה מריצים אותה. עדיין, התוצאה של הקוד שלעיל תהיה שונה בין מערכות הפעלה שונות, ואם נוציא אותה לפלט נקבל שני קבצים שונים, שבאופן עגום למדי הולכים להיראות זהים בחלק לא קטן ממעבדי התמלילים שבהם נפתח אותם (דווקא במעבד תמלילים פרימיטיבי כמו notepad של חלונות, קובץ עם ירידות שורה של יוניקס נראה כך בבירור כי לא מוצגות בו ירידות שורה). בעיות תאימות כאלו לא יהיו רלוונטיות לנו הפעם, אז נעזוב אותן.

מה שכן, אני כן רוצה לדבר על הסימן הנוסף שצץ לנו פתאום ויהיה חשוב מאוד בהמשך - r. מי זה ומה הוא אומר? השם שלו הוא carrige return (או CR), ומקורו הוא בכלל במכונות כתיבה. מכונות כתיבה ידניות הורכבו ממקשים שחוברו ל”פטישים” - ברזלים יצוקים עם אותיות על כל אחד מהם - בכל הקשה על מקש, הפטיש זינק קדימה, עבר דרך סרט דיו וגרם לו להתנגש בנייר שלפניו ולהטביע עליו אות. הנייר עצמו היה נתון במחזיק נע, ואחרי כל הקשת מקש המחזיק זז טיפה הצידה. כאשר הגענו לסוף השורה היה צורך לעשות שתי פעולות: להחזיר את המחזיק אחורה, לתחילת השורה; ולהזיז את הנייר כך שהפטישים יכו כעת בשורה הבאה. אויש, מכונות מכניות זה כזה דבר נפלא.

TypewriterHermes

CR בא לתאר את הפעולה של החזרת המחזיק לתחילת השורה. לעומתו, n\ בא לתאר את התהליך של ירידת השורה עצמה (הוא לרוב נקרא LF, מלשון Line feed). עכשיו, אני בטוח שבמהלך ההיסטוריה היו מכונות כתיבה מכל הסוגים והמינים - כאלו שכאשר ביצענו בהם (ידנית! הכל היה ידני!) פעולת CR אז זה גרם לפעולת LF להתבצע; כאלו שבהן אם ביצענו LF זה גרם ל-CR להתבצע; וכאלו שבהן היינו צריכים לבצע את שתיהן באופן ידני. כל אלו הן נסיבות היסטוריות שמנסות להסביר מאיפה הגיעו הסימנים המוזרים הללו, אבל לא רק, כי ל-r\ יש תפקיד עצמאי שאינו מתאר רק ירידת שורה: הוא מאפשר לנו לחזור לתחילת השורה הנוכחית ולכתוב מחדש עליה. זה מה שיאפשר לנו לבצע אנימציה-בת-שורה כפי שאנחנו רוצים. ולמי שזה נשמע לו מטופש ומיותר, חשוב לזכור שזה דווקא מאוד מועיל - בצורה הזו תוכניות ללא ממשק משתמש גרפי מסוגלות להציג סטטוס התקדמות (תכף אתן דוגמה) ובאופן כללי לתת למשתמש אינדיקציה לכך שהן עושות משהו ולא נתקעו, וזאת מבלי למלא את כל המסך בג’יבריש.

בואו נעבור לאקשן. יש רק דבר אחד שעדיין חסר לי, בתור אנימטור, וזה השהייה. מחשב מסוגל לבצע זיליארד פעולות בשניה; אבל אנימציה שבה אין השהייה בין פריימים סמוכים לא תיקלט טוב בעין האנושית. הפתרון? אחרי כל פריים שאנחנו מציירים, להגיד לרובי ללכת לישון לפרק זמן של 1 חלקי מספר הפריימים שאנחנו רוצים לצייר בשניה. איך עושים את זה? פשוט מאוד - פקודת sleep, שמקבלת מספר (שיכול להיות לא שלם) והולכת לישון למספר הזה של שניות.

עכשיו, קצת מפתה לתת פתרון בן שורה אחת, אז אעשה את זה, אבל הוא יהיה גרוע ואחר כך אתן פתרון טוב יותר:

100.times{|n| print "\r" + " "*n + "Hello world!"; sleep(0.1)}

מה הולך כאן? פשוט מאוד: אני כותב שורה שמתחילה ב-r\ (ולכן סמן הכתיבה חוזר לתחילת השורה אם לא היה שם כבר), ואז n רווחים (כש-n מתחיל מ-0), ואז את המחרוזת שאני רוצה לצייר. זה יצור את אשליית התמונה הרצויה. אז למה הקוד גרוע? ראשית, כשהוא מסיים את הריצה שלו אין ירידת שורה, ואז ה-prompt (הדבר הזה שבו מזינים טקסט בטרמינל) מופיע באותה שורה יחד עם ההודעה וזה פשוט נראה רע. שנית, הטקסט זז ימינה עוד ועוד אבל הוא בחיים לא יחזור שמאלה ויתחיל מחדש, כך שיש גבול עד כמה אפשר להזיז אותו, וזה גם נראה קצת טיפשי. לבסוף, הקוד הזה עמוס במספרי קסם וכבר אמרתי שהם דבר רע. אז הנה קוד שפותר את הבעיות הללו:

FRAMES_PER_SECOND = 20
TEXT = "Hello world!"
TOTAL_TIME = 10
SCREEN_WIDTH = 50

(TOTAL_TIME*FRAMES_PER_SECOND).times do |n|
  print "\r" + " "*(SCREEN_WIDTH+TEXT.length) if n % SCREEN_WIDTH == 0
  print "\r" + " "*(n % SCREEN_WIDTH) + TEXT
  sleep(1.0 / FRAMES_PER_SECOND)
end
puts

שורה 7 דורשת הסבר קצר - אני מוחק בה את השורה הקיימת, מכיוון שכתיבת r\ רק מחזירה אותי לתחילת השורה אבל לא מוחקת את תוכן השורה (ממש כמו במכונת כתיבה אמיתית). האופן שבו טקסט קיים בשורה יכול להימחק הוא רק על ידי כתיבת טקסט חדש עליו, אז אני פשוט כותב רווחים על כל החלק הרלוונטי בשורה.

עוד דבר שהיה אולי מתבקש לעשות הוא לאפשר לאנימציה לרוץ לנצח, עד שהמשתמש יקיש על מקש כלשהו. ויתרתי על זה להפעם כי זה מוסיף עוד רמת סיבוך לקוד ומצריך הצגה של עוד מושגים. לכן הריצה עדיין מוגבלת בזמן, שנמצא בקבוע TOTAL_TIME (שמייצג את הזמן בשניות).

בואו נעבור להסקל. מכיוון שאני רוצה להציג סיבוכים חדשים כאן ולא להתעסק בישנים, אני עושה משהו פשוט יחסית - טקסט שנע קצת על המסך ואז עוצר, בלי שיתחיל שוב מהצד השמאלי של המסך:

import Control.Concurrent (threadDelay)
import Data.Time.Clock
import System.IO

initialTicks = 10
printAnimation :: Int -> IO ()
printAnimation 0 = do
		putStrLn("")
printAnimation ticksRemaining = do
 		putStr("\r" ++ (replicate (initialTicks - ticksRemaining) ' ') ++ "Hello world!")
 		hFlush stdout
		threadDelay 100000
		printAnimation(ticksRemaining-1)

main = do printAnimation(initialTicks)

יש כאן כמה דברים שדורשים הסבר. נתחיל מהקל: ההגדרה של printAnimation נראית כמו “משהו שמקבל Int ומחזיר IO ויש שם גם סוגריים לא ברורים”. ובכן, לא. הסוגריים הלא ברורים בעצם אומרים “אין פלט לפונקציה”. המשמעות של ה-IO היא שונה וזו הזדמנות טובה להציג אותה בפירוט.

כפי שכבר אמרתי בפוסט הקודם של התלמיד והמחשב, שהציג פונקציות ברובי, ה”מטרה” של פונקציה היא לחשב ערך החזרה כלשהו ולהחזיר אותו. מלבד זאת, הפונקציה עשויה לעשות עוד דברים שישפיעו על “העולם” - למשל, להדפיס פלט; למשל, לשנות ערכים של משתנים גלובליים; למשל, לשנות את הערך של האובייקט שהפונקציה מופעלת “מתוכו” (מה זה אומר? אסביר בעתיד). לשינויים הללו קוראים Side effects (הציעו לקרוא לזה “תופעות לוואי” אבל אני לא חושב שזה תרגום מתאים במיוחד כאן). ל-Side effects יש חסרון בולט אחד: הם לא חלק מהגדרת הפונקציה. אנחנו לא יכולים להסתכל על ההגדרה של פונקציה ולהבין מה ההשפעות הנוספות שיהיו לפונקציה הזו על התוכנית שלנו. זה הרה אסון במיוחד כשמשתמשים במשתנים גלובליים, ואז פונקציה מורכבת שאנחנו מפעילים בלי להתעמק בקוד שלה עלולה לשנות משתנה שאנחנו מניחים באופן מובלע שהערך שלו לא ישתנה, ואז שערי הגיהנום נפתחים.

בהסקל אוהבים להשתמש במילה Pure כדי לכנות פונקציה ללא Side Effects. הרעיון בשפה הוא פשוט - פונקציות טהורות, טוב; פונקציות לא טהורות, רע! אבל מה לעשות שצריך פונקציות שעושות דברים לא טהורים, למשל הדפסה לפלט? ובכן, בהסקל מצאו דרך מחוכמת לעקוף את זה באמצעות משהו שנקרא Monads, שאני ממש לא הולך לנסות לתאר עכשיו כי הוא מסובך. השורה התחתונה של זה מבחינתנו, כרגע, היא שפונקציה שיש לה IO כזה לפני ערך ההחזרה מסוגלת לבצע פעולות “מלוכלכות” כמו הדפסה, אבל המחיר הוא שרק פונקציה “מלוכלכות” אחרות יכולות לקרוא לה (main היא אוטומטית “מלוכלכת”).

דבר אחר שהוא אולי לא ברור הוא ה-“hFlush stdout” שיש בקוד. מה הבעיה כאן? ובכן, אם נריץ את הקוד בלי השורה הזו אמנם יודפס Hello world, אבל רק במיקום הסופי שאליו המילה תגיע - לא נראה בכלל את האנימציה. הסיבה לכך היא שהדפסה למסך לא מתבצעת מיידית; קודם כל המידע שעומד להיות מועתק למסך מועתק לאיזור זכרון כלשהו. רק כאשר מתבצעת פעולה שנקראת flush, האיזור הזה בזכרון מועתק למסך עצמו. אני ממש לא בקיא בשאלת “מתי מתבצע flush באיזו שפה” - ברובי, למשל, לא הייתי צריך לדאוג לזה עד כה - אבל בהסקל, כל עוד לא הזנו ירידת שורה, לא מתבצע flush אלא אם אני אומר להסקל לעשות זאת במפורש, בעזרת “hFlush stdout”.

עוד דבר שאולי נראה מוזר הוא שבמקום לכתוב sleep אנחנו כותבים threadDelay. “חוטים” בתכנות משמשים בריצה מקבילית, אבל כאן אני לא באמת מבצע ריצה מקבילית, ואפשר בלב שקט לחשוב על threadDelay בתור דרך מסובכת לכתוב sleep.

בואו נעבור עכשיו לג’אווהסקריפט. בג’אווהסקריפט הסיפור קצת שונה, מהטעם הפשוט שאין “הדפסה למסך” במובן של רובי - אין טרמינל שאליו מדפיסים דברים. כלומר, יש (console) אבל הוא משמש בעיקר מפתחים והמשתמשים לא מסתכלים בו רוב הזמן. שדה המשחק של ג’אווהסקריפט הוא מסך הדפדפן עצמו, והיא יודעת לשנות (או ליצור) אלמנטים שמופיעים בו. אז במקום להדפיס שורות ולהשתמש ב-r\ אני פשוט אתעלק על איזה איזור במסך (שבהטמל אני מסמן על ידי תג pre שמוודא שכל האותיות יהיו באותו גודל כך שאפקט התנועה ייראה טוב) ואכתוב אותו מחדש שוב ושוב.

בעיה קצת יותר משמעותית היא שאין בג’אווהסקריפט פקודת sleep. גם לא בשם אחר. פשוט לא קיים. למה? לא יודע. כאשר קוד ג’אווהסקריפט רץ יש בעיה “לתקוע” אותו כי זה יכול לגרום לדפדפן עצמו להיתקע (למי זה לא קרה? זה בדרך כלל נגמר בהודעת “unresponsive script”), אבל אני משער שיכלו לממש את sleep בצורה שמונעת “תקיעה” שכזו. אז מה עושים? משתמשים במנגנון אחר של ג’אווהסקריפט שמאפשר לנו לעשות משהו דמוי שינה, אם כי בצורה קצת יותר מסורבלת. יש בג’אווהסקריפט שתי פונקציות, setInterval ו-setDelay, שמקבלות כקלט פונקציה אחרת, ומריצות אותה אחרי פרק זמן נתון כלשהו (שגם הוא ניתן כקלט). ההבדל הוא ש-setInterval מריצה שוב ושוב וצריך לומר לה להפסיק, ו-setDelay עושה את זה רק פעם אחת. אצלנו מתבקש יותר להשתמש ב-setInterval. ולמה אני לא אוהב את הפונקציות הללו? בקוד הנוכחי דווקא נראה שזה עובד אחלה, אבל בעולם האמיתי כבר נתקלתי בכל מני בעיות מעצבנות של “איך הפונקציה שמעבירים כקלט מכירה את המשתנים שהוגדרו בדיוק שניה לפני שעשינו את setInterval” ועוד כמה מריעין בישין שאין לי כוח לדבר עליהם. אני מרגיש שהקטע הזה הוא חולשה בולטת ומעצבנת של השפה, אבל ייתכן שאני פשוט מפספס שיקול עיצובי הכרחי שגרם לכך.

הנה הקוד, ומתחתיו הדגמה חיה של הקוד בפעולה:

<html>
<head>
<title>Targil 15</title>
</head>
<body>
  <script type="text/javascript">
  var animation_text;
  var animation_handle;

  var animation_step = function(){
	if (animation_text.length <= 55){
		document.getElementById("animation").innerHTML = animation_text;
		animation_text = " " + animation_text;
	}
	else{
		animation_text = animation_text.trim();
	}
  }

  var start_running = function(){
	clearInterval(animation_handle);
	if (document.getElementById("activation_button").value == "Start"){
		document.getElementById("activation_button").value = "Stop";
		animation_text = document.getElementById("name").value;
		animation_handle = setInterval(animation_step,50);
	}
	else{
		document.getElementById("activation_button").value = "Start";
	}
  }
  </script>
  Name: <input type="textbox" id="name" value="Hello World!"/>
  <input type="button" value="Start" id="activation_button" onclick="start_running()" />
  <pre id="animation"></pre>
</body>
</html>

Name:



אני מקווה שרוב הקוד מובן, רק צריך להסביר את עניין clearInterval שמפסיק את ההרצה המתוזמנת של פונקציה. כדי לעשות את זה הוא צריך לקבל “קישור” לפונקציה שצריך להפסיק לתזמן - קישור כזה נוצר כאשר מפעילים עליה את setInterval מלכתחילה, ואני שומר אותו למשתנה הגלובלי animation_handle שאחר כך מועבר ל-clearInterval. פרט לכך עשיתי את האנימציה אינסופית, ולשמחתכם הרבה ברגע האחרון נמלכתי בדעתי והוספתי לכפתור ההפעלה גם את האפשרות לעצור את האנימציה. אחרת רע ומר מה שהיה קורה פה.


נהניתם? התעניינתם? אם תרצו, אתם מוזמנים לתת טיפ:

Buy Me a Coffee at ko-fi.com