הבלוג של ינון פרק

טיפים קצרים וחדשות למתכנתים

הערך האמיתי של Redux Selectors

18/07/2023

בתיעוד של reselect מספרים על היתרונות בשימוש בספריה - למשל שאפשר להרכיב סלקטורים כדי להשתמש מחדש בקוד שכבר כתבנו או שסלקטורים זוכרים את הערך הקודם שחישבו וכך יכולים לתת תוצאה מהר יותר אם לקוח מנסה לחשב את אותו ערך מספר פעמים (ממספר קומפוננטות שונות).

אבל הערך האמיתי של Selectors מתגלה כשאנחנו צריכים לארגן מחדש את ה Store.

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

הסיבה היא שבקוד הקומפוננטה יש התיחסות ישירה לשם השדה ולמקום שלו בתוך אוביקט ה Store:

function TodoCount() {
  const todos = useSelector(state => state.todos)
  return <p>{todos.length} things to do</p>
}

מבנה טוב יותר ייקח את כל הקריאות האלה ל useSelector לקובץ אחד, קובץ הסלקטורים. שם יהיה לנו למשל:

export const getTodos = state => state.todos;

ובתוך הקומפוננטה נכתוב רק:

function TodoCount() {
  const todos = useSelector(getTodos)
  return <p>{todos.length} things to do</p>
}

אפילו בלי Reselect ובלי ה Memoization הרווחנו המון - אם נקיד לכתוב את כל הסלקטורים במקום אחד, שינוי במבנה הסטייט או שינויי שמות יהיו הרבה יותר קלים למימוש ולא ידרשו שינוי בקומפוננטות.

היה שלום אפולו

17/07/2023

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

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

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

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

היה שלום אפולו ותודה על כל הלינקים.

תבנית לכתיבת בדיקה לבוט ב python-telegram-bot

16/07/2023

ספריית python-telegram-bot מציעה ממשק מאוד מקיף לבניית בוטים לטלגרם. הספריה מגיעה עם אינסוף דוגמאות ותיעוד מפורט, ולמרות זאת הדף על כתיבת בדיקות בויקי שלהם כמעט ריק. אחרי קצת חפירה בקוד שלהם הגעתי לתבנית הבאה לבדיקת יחידה ב pytest לבוט שכתוב ב python-telegram-bot. נתחיל בקוד ואחריו ההסברים:

from telegram import Message, MessageEntity, User, Chat, Update
import pytest
from unittest.mock import MagicMock
from datetime import datetime
import client.telegram.bot as bot

class AsyncMock(MagicMock):
    async def __call__(self, *args, **kwargs):
        return super(AsyncMock, self).__call__(*args, **kwargs)



@pytest.mark.asyncio
async def test_create_user(monkeypatch):
    with monkeypatch.context() as m:
        app = bot.create_application()
        await app.initialize()

        message = Message(
            message_id=0,
            date=datetime.utcnow(),
            chat=Chat(3, "private"),
            from_user=User(first_name='Misses Test', id=123, is_bot=False),
            text="/start",
            entities=[MessageEntity(type=MessageEntity.BOT_COMMAND,
                                    offset=0, length=len('/start'))]
        )
        message.set_bot(app.bot)

        send_message = AsyncMock()
        m.setattr(app.bot, "_send_message", send_message)

        await app.process_update(Update(update_id=0, message=message))
        assert 'Hello World' in send_message.call_args[0][1]['text']

והקוד עבור הבוט המתאים, מתוך קובץ שבדוגמה שלנו נקרא client/telegram/bot.py יהיה:

async def start_conversation(update: Update, context: ContextTypes.DEFAULT_TYPE):
    await update.message.reply_text(f"Hello World")


def create_application():
    # Create the Application and pass it your bot's token.
    token = os.environ.get("TELEGRAM_BOT_TOKEN")
    application = Application.builder().token(token).build()

    application.add_handler(CommandHandler('start', start_conversation))
    return application


def main() -> None:
    """Start the bot."""
    application = create_application()
    # Run the bot until the user presses Ctrl-C
    application.run_polling()


if __name__ == "__main__":
    print("Starting...")
    main()

בואו נראה מה היה לנו:

  1. קוד הבדיקה הוא פונקציה אסינכרונית. בשביל שזה יעבוד יש להתקין את הפלאגין pytest-asyncio.

  2. הבדיקה צריכה לזייף הודעת /start ואז צריכה לוודא שהבוט ענה בהודעת Hello World. בשביל זה אנחנו ממש יוצרים את הבוט (תחילת קוד הבדיקה) ומפעילים את app.initialize שזו פונקציה של python-telegram-bot שדואגת לכל מיני איתחולים. בחיבור רגיל לטלגרם קריאה ל run_polling מפעילה את ה initialize אוטומטית.

  3. הפונקציה process_update היא נקודת הכניסה לבוט והיא מקבלת אוביקט הודעה ומנתבת אותו לפונקציית הטיפול המתאימה לפי הכללים שהגדיר הבוט (אלה פקודות ה add_handler שרואים בקוד הבוט). בקוד הבדיקה משתמשים בה כדי לשלוח את הודעת ה /start.

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

  5. הפקודה set_bot היא שמאפשרת בתוך קוד הבוט לגשת ל update.message.reply_text, ולכל שאר הפונקציות של הבוט דרך אוביקט ההודעה.

  6. בשביל לקבל בחזרה לבדיקה את הפרמטרים שנשלחו לפונקציות שליחת ההודעה (אלה שהיו אמורים להישלח לטלגרם בהרצה אמיתית של הבוט), אני יוצר MagicMock ושומר אותו בשדה _send_message של אוביקט הבוט. הפונקציה _send_message נועדה לשימוש פנימי ולכן אפשר לדרוס אותה. ניסיון לדרוס פונקציות ציבוריות של הבוט ייכשל בגלל הקוד הבא בתוך ספריית python-telegram-bot באוביקט הבוט:

def __setattr__(self, key: str, value: object) -> None:
    """Overrides :meth:`object.__setattr__` to prevent the overriding of attributes.

    Raises:
        :exc:`AttributeError`
    """
    # protected attributes can always be set for convenient internal use
    if key[0] == "_" or not getattr(self, "_frozen", True):
        super().__setattr__(key, value)
        return

    raise AttributeError(
        f"Attribute `{key}` of class `{self.__class__.__name__}` can't be set!"
    )
  1. אחרי דריסת _send_message נשאר רק לשלוח את ההודעה ל process_update ולוודא את ההודעה שהבוט שולח דרך בדיקה של הקריאות שבוצעו ל Mock בשורה האחרונה של הבדיקה.

ללא מגע יד

15/07/2023

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

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

  1. מערכת שרצה על שרת שלי עדיפה על מערכת בענן.

  2. מערכת שרצה בענן עדיפה על מערכת שתרוץ בדפדפן אצל משתמשים.

  3. קוד שאני כתבתי עדיף על שימוש בספריה קיימת.

  4. ספריה קיימת עדיפה על פנייה ל API ציבורי צד-שלישי.

  5. מיזלוג (fork) ספריה קיימת עדיף על כתיבת קוד שמשתמש במנגנונים לא מתועדים שלה.

  6. ממשק מבוסס טקסט עדיף על ממשק גרפי.

  7. כלי קוד פתוח עדיפים על כלים קנייניים.

  8. שמירת מידע בקבצי טקסט עדיפה על שמירתו בבסיס נתונים.

  9. שימוש במידע מקומי עדיף על שימוש במידע מהרשת.

גיוון הוא כבר לא יעד

14/07/2023

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

צריך לשים לב למגמה הזאת במיוחד אם אתם שייכים לאוכלוסיות הלא מיוצגות. כמה טיפים ומחשבות:

  1. התחרות קשה במיוחד עבור ג'וניורים או עבור עובדים מרוחקים.

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

  3. היו פתוחים להזדמנויות גם בחו"ל. לפעמים יותר קל לשפר את האנגלית מאשר לעבור מגייסים שפוחדים לטעות.

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

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

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

טיפ neo4j: איחוד צמתים

13/07/2023

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

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

במצב כזה אפשר להריץ את השאילתה הבאה כדי לאחד צמתים שונים המכילים טקסט של אותה מילה:

MATCH (w:Word)-[:IN_LANGUAGE]-(l:Language)

CALL {
    WITH w
    MATCH (m:Word)-[:IN_LANGUAGE]-(l)
    WHERE m <> w AND m.text = w.text
    RETURN w AS innerWord
}

WITH innerWord.text as text, collect(innerWord) AS nodes
CALL apoc.refactor.mergeNodes(nodes) YIELD node
RETURN node;

בואו נקרא ונתרגם:

  1. שורה ראשונה מחפשת מילים. כל פעם שהיא מוצאת מילה בבסיס הנתונים היא שומרת את הצומת במשתנה בשם w, ואת השפה שבה המילה כתובה במשתנה בשם l.

  2. בתוך בלוק ה CALL מופעלת תת-שאילתה. תת השאילתה לוקחת את w מבחוץ ומחפשת צמתים אחרים של מילים שאינם w אבל הן באותה שפה ויש להן את אותו טקסט.

  3. מחוץ לבלוק ה CALL הפקודה WITH מייצרת אגרגציה ומסדרת את השורות שחוזרות מהשאילתה - בכל שורה יהיה הטקסט של המילה ורשימת כל צמתי המילים שיש להם את הטקסט הזה.

  4. פקודת ה CALL בשורה הלפני האחרונה ממזגת את כל הצמתים בכל שורת פלט ובסוף מחזירה את הצומת שנוצר.

סך הכל Cypher (שזו שפת השאילתות של neo4j) יותר פשוט לכתיבה וקריאה מ SQL, וכולל המון פונקציות מובנות כמו collect ו mergeNodes שעוזרות בכתיבת שאילתות.

נ.ב. הפיל שבחדר הוא כמובן חוסר התמיכה של neo4j באינדקס ייחודי על מספר מאפיינים (במקרה שלנו טקסט של מילה + השפה שלה). יש שמועה שמנגנון כזה קיים בגירסת ה Enterprise שלהם אבל לא ניסיתי. אופציה שכן עובדת במקום להיתקל בבאגים מסוג זה היא ליצור מאפיין ייחודי בצומת שיהיה מורכב מהטקסט של המילה וקוד שפה שלה. כמובן שאז צריך לזכור ליצור ולעדכן את המאפיין הזה מתוך הקוד בכל עבודה עם הצומת.

הסיפור סביב "לא סיימתי את כל התרגילים"

12/07/2023

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

יש לנו סיפור סביב "לסיים את כל התרגילים". סיום התרגילים נותן תחושה של הישגיות וביטחון. אני יודע שאני על המסלול. אני יודע שאני בסדר. אני יודע שבסוף המסלול אצליח לעשות את מה שרציתי. הצלחתי לסמן וי בכל המשבצות. עשיתי את מה שצריך.

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

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

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

סיימת את כל התרגילים? מהמם. סיימת חלק מהתרגילים? מצוין. סיימת יותר ממה שמופיע ברשימה? אחלה. אנחנו פה בשביל ללמוד לא בשביל לסמן וי.

טיפ פייתון: הרצת קוד JavaScript עם js2py

11/07/2023

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

המודול עוזר לפרויקטי Web Scraping או בדיקות, כשאנחנו לא רוצים או צריכים להפעיל סלניום מלא ובסך הכל רוצים לראות שה JavaScript עבד מספיק טוב או לקבל ממנו מידע. בהרבה אתרים יש קובץ JavaScript שמתאר כל מיני אופציות של האתר, לדוגמה באתר של dramatiq יש קובץ JavaScript שנראה כך:

var DOCUMENTATION_OPTIONS = {
    URL_ROOT: document.getElementById("documentation_options").getAttribute('data-url_root'),
    VERSION: '1.14.2',
    LANGUAGE: 'en',
    COLLAPSE_INDEX: false,
    BUILDER: 'html',
    FILE_SUFFIX: '.html',
    LINK_SUFFIX: '.html',
    HAS_SOURCE: true,
    SOURCELINK_SUFFIX: '.txt',
    NAVIGATION_WITH_KEYS: false,
    SHOW_SEARCH_SUMMARY: true,
    ENABLE_SEARCH_SHORTCUTS: true,
};

מה אם אני רוצה להשתמש במידע הזה מקוד פייתון שבודק את האתר או שמושך ממנו מידע? כן אני יכול לפרסר את הטקסט, אבל לא יהיה יותר קל פשוט להריץ את הקוד ולקחת את האוביקט בתור Dictionary? מסתבר שעם js2py זה מאוד פשוט:

code = requests.get('https://dramatiq.io/_static/documentation_options.js').text
ctx = js2py.EvalJs()
ctx.execute("document = {getElementById() { return {getAttribute() { return null }}}}")
ctx.execute(code)
print(ctx.DOCUMENTATION_OPTIONS)

data = ctx.DOCUMENTATION_OPTIONS.to_dict()
print(len(data))

התוכנית תדפיס את האוביקט בתור מילון של פייתון ואחר כך את מספר המפתחות שבו.

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

היו שלום defaultProps על פונקציות

10/07/2023

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

function User({name="Guest"}) {
...
}

ולא ב-

function User(props) {
}

User.defaultProps = {
    name: 'Guest',
};

הכתיב ימשיך להיתמך ב Class Components, ויתווסף לרשימת הדברים ש"צריך ללמוד" כדי להשתמש ב Class Components ולא עובדים ב Function Components.

היו שלום defaultProps. אני אתגעגע.

לא למבחן

09/07/2023

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

מוח שמעדיף לקרוא תשובה מהירה של ChatGPT מאשר את כל ה Man Page.

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

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

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

"ומה עוד תרצה לדעת על הנושא?" היא פריווילגיה שאפשר ליהנות ממנה רק כשאין מבחן בסוף.