2.6 - ייצוג מצב ופעולות בספרייה - הרצאה
אפס יכול להיות תשובה נכונה¶
נניח שכתבנו פונקציה שמחזירה גודל של קובץ. המספר אפס יכול להיות גודל תקין של קובץ ריק, אבל קל להשתמש בו גם לציון קובץ שלא נמצא או כשל בקריאה. ברגע שמערבבים את המצבים, הקורא אינו יכול לדעת מה קרה באמת.
אפשר להחזיר מספר וקוד שגיאה נפרדים, אבל אז צריך להגדיר אילו צירופים חוקיים. טיפוס שמייצג את האפשרויות במפורש מצמצם את מספר המצבים הלא הגיוניים שאפשר ליצור.
נתחיל בחיפוש בזיכרון, שבו היעדר ערך הוא תוצאה רגילה:
#include <cassert>
#include <optional>
#include <span>
std::optional<int> first_positive(std::span<const int> input) {
for (int value : input) {
if (value > 0) return value;
}
return std::nullopt;
}
int main() {
const int first[]{-1, 0, 7};
const int second[]{-1, 0};
assert(first_positive(first) == 7);
assert(!first_positive(second));
}
ה־optional מחזיקה או int חי או מצב ריק. היא אינה מחזיקה קוד שגיאה שמסביר למה אין ערך, ולכן מתאימה כאשר היעדר הוא חלק מן החוזה ואין מידע נוסף שצריך לשמור.
הגישה באמצעות כוכבית דורשת שיש ערך. אפשר להשתמש ב־value כדי לקבל התנהגות כשל מוגדרת במקרה ריק, אבל זו כבר החלטה על טיפול בשגיאות. עצם בחירת optional אינה פוטרת את הקורא מבדיקת המצב.
שלושה מצבים נפרדים¶
בפעולת filesystem נרצה להבחין בין קובץ חסר לכשל אחר. הקוד הבא משתמש ב־variant כדי לייצג את האפשרויות:
#include <cstdint>
#include <filesystem>
#include <system_error>
#include <variant>
struct Missing {};
struct FileSize { std::uintmax_t bytes; };
struct Failure { std::error_code error; };
using SizeResult = std::variant<Missing, FileSize, Failure>;
SizeResult inspect_size(const std::filesystem::path& path) {
std::error_code error;
auto bytes = std::filesystem::file_size(path, error);
if (!error) return FileSize{bytes};
if (error == std::errc::no_such_file_or_directory) {
return Missing{};
}
return Failure{error};
}
הפונקציה מקבלת path ומבצעת פעולה אחת. היא אינה בודקת exists ואז מניחה שהקובץ נשאר ללא שינוי עד הקריאה הבאה. גם הפעולה הבודדת אינה snapshot של כל מערכת הקבצים; היא מחזירה מידע לפי חוזה הפעולה שבוצעה.
הטיפוסים FileSize ו־Failure מבהירים את המשמעות של הנתונים. החזרת FileSize שערכה אפס היא הצלחה, ואינה מתבלבלת עם Missing. הדוגמה מפרשת רק קוד חסר מסוים כהיעדר; כשל אחר נשמר כדי שהקורא יוכל להחליט כיצד להגיב.
קריאת תוצאה בלי לנחש¶
הקוד הבא מצטרף לאותו קובץ ומספק main מלא:
#include <cstdio>
int main() {
SizeResult result = inspect_size(L"example.txt");
if (auto* size = std::get_if<FileSize>(&result)) {
std::printf("bytes=%llu\n",
static_cast<unsigned long long>(size->bytes));
} else if (std::holds_alternative<Missing>(result)) {
std::puts("missing");
} else {
const auto& error = std::get<Failure>(result).error;
std::fprintf(stderr, "%s\n", error.message().c_str());
return 1;
}
}
הקריאה ל־get_if מחזירה pointer רק כשהחלופה מתאימה. היא אינה מעתיקה את התוכן, ולכן הpointer תלוי בחיי ה־variant ובכך שהחלופה לא תוחלף.
אפשר להשתמש גם ב־visit כדי לדרוש טיפול לפי סוג החלופה. הבחירה בין הגישות תלויה במבנה הממשק, אבל בשתיהן צריך להיזהר לא לאבד מצב חדש שמוסיפים בהמשך.
ב־C++23 קיימת גם expected לייצוג הצלחה או שגיאה. השימוש בה תלוי בתמיכת הספרייה שבחרנו, ולכן אין לכלול אותה במקור שמבטיח להיבנות בכל סביבת C++20. ה־variant בדוגמה מאפשר לנו ללמוד את החוזה בלי לדרוש תכונה חדשה יותר.
חריגות עדיין אפשריות¶
גרסת file_size שמקבלת error_code מאפשרת לדווח על שגיאות מערכת צפויות דרך הפרמטר. זה אינו בהכרח חוזה שכל פעולה סביב הקריאה, למשל יצירת path או הקצאת הודעה, לעולם לא תזרוק.
גם variant אינה מנגנון שמבטיח שלעולם לא יקרה כשל בהחלפת חלופות. במצבים מסוימים של חריגה בזמן שינוי תוכן היא יכולה להיות valueless_by_exception. נלמד בפרק הבא איך דרישות move ו־noexcept משפיעות על תכנון מצב כזה.
הלקח הוא להגדיר איזה שכבת שגיאות הטיפוס מייצג. קוד שגיאה מפורש אינו ביטול של כל מנגנון כשל אחר בתוכנית.
פונקציה יכולה לשמור מצב¶
למבדה יוצרת אובייקט closure. כאשר כותבים capture לפי ערך, האובייקט שומר עותק או משאב שהועבר אליו. כאשר כותבים capture לפי reference, נשמר קשר למצב חיצוני שחייב להישאר חי.
התוכנית הבאה מייצרת פעולה עם מצב עצמאי:
#include <cstdio>
auto make_counter(int start) {
return [value = start]() mutable {
return ++value;
};
}
int main() {
auto next = make_counter(10);
std::printf("%d %d\n", next(), 0);
std::printf("%d\n", next());
}
הדוגמה קוראת ל־next פעם אחת בכל קריאה ל־printf, כדי שלא לערבב לימוד מצב עם שאלת סדר הערכת שני ארגומנטים. ה־mutable מאפשר לשנות את העותק שבתוך הclosure, ולא את start המקומי שכבר אינו קיים.
אילו היינו מחזירים למבדה שתופסת את start לפי reference, היינו משאירים dangling reference. עטיפה ב־std::function לא מתקנת זאת; היא מחזיקה את הclosure, לא בהכרח את האובייקטים שהclosure שואלת.
זמן הוא גם טיפוס¶
כאשר מתכננים timeout, חשוב להבדיל בין duration לבין נקודת זמן. הספרייה chrono מספקת טיפוסים שמייצגים יחידות, כך שקשה יותר לחבר בטעות מילישניות כאילו היו שניות.
למדידת משך פעולה משתמשים בשעון מונוטוני מתאים כמו steady_clock. שעון קיר יכול להשתנות בגלל עדכון זמן, ולכן אינו בסיס טוב לחישוב משך על ידי חיסור של שתי תצפיות שאינן מבטיחות מונוטוניות.
המרה באמצעות duration_cast יכולה לאבד שבר של יחידה. לכן צריך להחליט אם רוצים חיתוך, עיגול או שמירה ביחידה מדויקת יותר. הדפסת מספר בלי יחידה משאירה את הקורא לנחש את החוזה.
נתיבים וטקסט אינם אותם bytes¶
ב־Windows הייצוג native של filesystem::path מתאים לנתיבי המערכת. בניית path ממחרוזת narrow דורשת תשומת לב לקידוד, ואין להניח שכל string מכילה UTF-8 וכל פונקציה תפרש אותה כך.
הדוגמה השתמשה ב־wide literal כדי לצמצם את ההנחות. ממשק שמקבל טקסט מרשת צריך להגדיר את הקידוד ולהמיר בגבול המתאים, במקום להניח ששינוי טיפוס מ־string ל־path פותר את הבעיה.
גם formatting הוא חלק מהחוזה: מציינים יחידות, שומרים טיפוס שיכול להכיל את הערך ומשתמשים במנגנון הדפסה תואם. תוצאה נכונה שמוצגת בטיפוס או בקידוד שגוי עדיין יכולה להטעות את מי שמאבחן את המערכת.