גבולות ספרייה ותאימות¶
נניח שכתבנו ספרייה שמנהלת תור הודעות. היום אנחנו בונים את הספרייה ואת התוכנית שמשתמשת בה יחד, ולכן שינוי במחלקה נראה כמו עניין פשוט. מחר הספרייה מגיעה כ־DLL, והתוכנית של לקוח מסוים ממשיכה להשתמש בקובץ ההפעלה שנבנה לפני שנה. באותו רגע מתגלות שתי שאלות שונות: האם הקוד הישן עדיין מתקמפל, והאם הקוד הבינארי הישן עדיין יודע לקרוא לספרייה החדשה.
התאימות ברמת המקור נקראת source compatibility. התאימות הבינארית נקראת ABI compatibility. הצלחה של הראשונה אינה מבטיחה את השנייה. הוספת שדה פרטי למחלקה עשויה לא לשנות שום קריאה ציבורית, ובכל זאת לשנות את גודל המחלקה שהלקוח מקצה במחסנית.
הסתרת ייצוג מאחורי בעלות¶
בואו נכתוב מחלקה שהלקוחות יודעים את גודלה בלי לדעת את הייצוג הפנימי שלה. שומרים את הקובץ הבא בשם counter.hpp.
#pragma once
#include <memory>
class Counter {
public:
Counter();
~Counter();
Counter(Counter&&) noexcept;
Counter& operator=(Counter&&) noexcept;
Counter(const Counter&) = delete;
Counter& operator=(const Counter&) = delete;
void add(int value);
int value() const;
private:
struct Impl;
std::unique_ptr<Impl> impl_;
};
הלקוח רואה הצהרה על טיפוס לא שלם בשם Impl. הוא אינו יכול לגשת לשדות שלו, אך מותר לו להחזיק מצביע אליו. המימוש נמצא בקובץ counter.cpp.
#include "counter.hpp"
#include <stdexcept>
#include <utility>
struct Counter::Impl {
int total = 0;
};
Counter::Counter() : impl_(std::make_unique<Impl>()) {}
Counter::~Counter() = default;
Counter::Counter(Counter&&) noexcept = default;
Counter& Counter::operator=(Counter&&) noexcept = default;
void Counter::add(int value) {
if (!impl_) throw std::logic_error("moved-from counter");
if (value < 0 || value > 1000 - impl_->total)
throw std::out_of_range("counter limit");
impl_->total += value;
}
int Counter::value() const {
if (!impl_) throw std::logic_error("moved-from counter");
return impl_->total;
}
ההגדרה של ה־destructor נמצאת בקובץ שבו Impl כבר שלם. בזמן השמדת unique_ptr צריך להשמיד גם את האובייקט המוחזק, ולכן מיקום ההגדרה חשוב. כתיבת destructor שמוגדר אוטומטית בתוך ה־header עלולה לחייב את קוד הלקוח להתמודד עם טיפוס שאינו שלם.
המחלקה משתמשת בתבנית pImpl. אפשר להוסיף ל־Impl סטטיסטיקות או להחליף את הייצוג בלי להוסיף שדות לאובייקט Counter שבידי הלקוח. המחיר הוא הקצאה נוספת ובדרך כלל גישה עקיפה. התבנית מצמצמת תלות בייצוג, אך אינה מבטיחה לבדה ABI יציב בין כל מהדר וספרייה תקנית.
מצב אחרי העברה הוא חלק מהממשק¶
אחרי העברת Counter, האובייקט המקורי מחזיק מצביע ריק. בחרנו לאפשר השמדה והשמה, ולדחות קריאה ל־add או ל־value באמצעות חריגה. אפשר לבחור חוזה אחר, אבל אסור להשאיר את הבחירה ליד המקרה.
ניצור main.cpp ונריץ מתוך חלון כלי הבנייה.
#include "counter.hpp"
#include <cstdio>
#include <utility>
int main() {
Counter first;
first.add(7);
Counter second = std::move(first);
std::printf("%d\n", second.value());
}
כעת הוסיפו שדה ל־Impl ובנו מחדש. הקוד שב־header אינו משתנה. עדיין לא הוכחנו תאימות DLL, מפני שבנינו את כל התוכנית מחדש. בפרק הבא נבדוק באופן מכוון לקוח ישן מול ספרייה חדשה ונעקוב אחר הסמלים המיוצאים.
גבול צר עם ממשק C¶
כשצריך ממשק שרכיבים שונים יוכלו לצרוך, אפשר לחשוף handle אטום ומספר קטן של פונקציות. הקטע הבא הוא חוזה header, ולא מימוש מלא של DLL.
#ifndef COUNTER_API_H
#define COUNTER_API_H
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
typedef struct counter_state* counter_handle;
enum counter_status {
COUNTER_OK = 0,
COUNTER_INVALID = 1,
COUNTER_LIMIT = 2,
COUNTER_MEMORY = 3,
COUNTER_INTERNAL = 4
};
uint32_t counter_api_version(void);
int counter_create(counter_handle* out);
int counter_add(counter_handle value, int32_t amount);
int counter_read(counter_handle value, int32_t* out);
void counter_destroy(counter_handle value);
#ifdef __cplusplus
}
#endif
#endif
המילה extern "C" מגדירה language linkage. היא אינה הופכת מצביע שגוי לבטוח, אינה מגדירה קידוד טקסט ואינה מבטלת הבדלי ארכיטקטורה. ב־Windows נצטרך גם להגדיר export/import ומוסכמת קריאה מתאימה, כפי שנעשה בפרק הבא.
החוזה צריך לומר מי יוצר את ה־handle ומי משמיד אותו. המימוש שמקצה את המצב הוא גם המימוש שמשחרר אותו. הלקוח אינו מפעיל delete על הטיפוס האטום ואינו מנחש את גודלו. הפעולה counter_destroy(nullptr) יכולה להיות מוגדרת כחסרת השפעה, אבל קריאה עם handle שכבר הושמד נשארת הפרת חוזה.
כשל אינו יכול לדלוף דרך גבול לא מוגדר¶
אם counter_create משתמשת ב־new, היא עלולה לקבל std::bad_alloc. המעטפת תופסת את החריגה ומחזירה COUNTER_MEMORY. היא מאפסת את פרמטר הפלט לפני הניסיון, כדי שהלקוח לא יפרש ערך ישן כהצלחה. חריגות אחרות מתורגמות לשגיאה מתועדת, בלי להבטיח התאוששות מתקלה שהשחיתה את התהליך.
העברת std::string או std::vector דרך גבול DLL קושרת אותנו לייצוג ול־runtime המתאימים. ממשק שמקבל pointer ו־length אינו מספיק כשלעצמו: צריך להגדיר אם האורך נמדד בבתים, אם המצביע יכול להיות ריק, מי מחזיק בבעלות, וכמה זמן מותר לספרייה לשמור אותו.
גם הוספת ערך חדש ל־enum יכולה לשבור לקוח שמניח שקיימים בדיוק ארבעה ערכים. לכן לקוח צריך לטפל בקוד שגיאה שאינו מוכר לו. בגרסה חדשה אפשר להוסיף פונקציה חדשה בלי לשנות את המשמעות של פונקציה קיימת, או לחשוף גודל מבנה וגרסת חוזה כשיש צורך בהרחבה.
בדיקת תאימות היא ניסוי עם שני זמנים¶
הניסוי הנכון שומר תוצר בינארי ישן, מחליף רק את הספרייה ובודק את ההתנהגות. ניסוי נוסף בונה מקור ישן מול header חדש. כל אחד עונה על שאלה אחרת. גם אם שניהם עברו, עדיין צריך לבדוק את מוסכמת השגיאות ואת משמעות התוצאות.
הפגם המעניין בגבול ספרייה הוא לעיתים פגם של ציפיות. הספרייה החדשה יכולה להחזיר שדה נכון מבחינת הטיפוס אך ביחידה אחרת, למשל תווים במקום בתים. לכן תיעוד חוזה, בדיקות התנהגות ושמירת גרסאות חשובים לא פחות מבדיקת layout.
לקריאה נוספת: התיעוד של Microsoft על העברת אובייקטי CRT בין גבולות DLL.