// faststringitf.срр
// (part of DLL, not client)
// (часть DLL, а не клиента)
#include «faststring.h»
#include «faststringitf.h»
FastStringItf::FastStringItf(const char *psz) : mpThis(new FastString(psz))
{ assert(mpThis != 0); }
FastStringItf::~FastStringItf(vo1d)
{ delete mpThis; }
int FastStringItf::Length(void) const
{ return mpThis->Length(); }
int FastStringItf::Find(const char *psz) const
{ return mpThis->Find(psz); }
Эти передающие методы должны быть транслированы как часть DLL FastString, так что когда двоичное представление класса реализации FastString меняется, вызов нового оператора в конструкторе FastStringItf будет сразу же перекомпилирован, если, конечно, зарезервировано достаточно памяти. И опять клиент не получит описания класса реализацииFastString. Это дает разработчику FastString возможность со временем развивать реализацию без прерывания существующих клиентов.
Рисунок 1.4 показывает, как использовать классы-дескрипторы для отделения интерфейса от реализации на этапе выполнения. Заметим, что косвенный подход, введенный классом интерфейса, устанавливает двоичную защитную стену (firewall – брандмауэр) между клиентом и реализацией объекта. Эта двоичная стена очень точно описывает, как клиент может сообщаться с реализацией. Все связи клиент-объект осуществляются через класс интерфейса, который содержит очень простой двоичный протокол для входа в область реализации объекта. Этот протокол не содержит никаких деталей класса реализации в C++.
Хотя методика использования классов-дескрипторов имеет свои преимущества и безусловно приближает нас к возможности безопасного извлечения классов из DLL, она также имеет свои недостатки. Отметим, что класс интерфейса вынужден явно передавать каждый вызов метода классу реализации. Для простого класса вроде FastString только с двумя открытыми операторами, конструктором и деструктором, это не проблема. Для большой библиотеки классов с сотнями или тысячами методов написание этих передающих процедур было бы весьма утомительным и явилось бы потенциальным источником ошибок. Кроме того, для областей с повышенными требованиями к эффективности программ (performance-critical domains), цена двух вызовов для каждого метода (один вызов на интерфейс, один вложенный вызов на реализацию) весьма высока. Наконец, методика классов-дескрипторов не полностью решает проблемы совместимости транслятора/компоновщика, а они все же должны быть решены, если мы хотим иметь основу, действительно пригодную для создания компонентов повторного использования.
Абстрактные базы как двоичные интерфейсы
Оказывается, применение техники разделения интерфейса и реализации может решить и проблемы совместимости транслятора/компоновщика C++. При этом, однако, определение класса интерфейса должно принять несколько иную форму. Как отмечалось ранее, проблемы совместимости возникают из-за того, что разные трансляторы имеют различные соображения по поводу того, как
1. передавать особенности языка на этапе выполнения;
2. символические имена будут представлены на этапе компоновки.
Если бы кто-нибудь придумал, как скрыть детали реализации транслятора/компоновщика за каким-либо двоичным интерфейсом, это сделало бы написанные на C++ библиотеки DLL значительно более широко используемыми.
Двоичная защита, то есть тот факт, что класс интерфейса C++ не использует языковых конструкций, зависящих от транслятора, решает проблему зависимости от транслятора/компоновщика. Чтобы сделать эту независимость более полной, необходимо в первую очередь определить те аспекты языка, которые имеют одинаковую реализацию в разных трансляторах. Конечно, представление на этапе выполнения таких сложных типов, как С-структуры (structs), может быть выдержано инвариантным по отношению к трансляторам. Это – основное, что должен делать системный интерфейс, основанный на С, и иногда это достигается применением условно транслируемых определений типа прагм (pragmas) или других директив транслятора. Второе, что следует сделать, – это заставить все компиляторы проходить параметры функций в одном и том же порядке (слева направо, справа налево) и зачищать стек также одинаково. Подобно совместимости структур, это также решаемая задача, и для унификации работы со стеком часто используются условные директивы транслятора. В качестве примера можно привести макросы WINAPI/WINBASEAPI из Win32 API. Каждая извлеченная из системных DLL функция определена с помощью этих макросов:
WINBASEAPI void WINAPI Sleep(DWORD dwMsecs);
Каждый разработчик транслятора определяет эти символы препроцессора для создания гибких стековых фреймов. Хотя в среде производителей может возникнуть желание использовать аналогичную методику для определений всех методов, фрагменты программ в этой главе для большей наглядности ее не используют.
Третье требование к независимости трансляторов – наиболее уязвимое для критики из всех, так как оно делает возможным определение двоичного интерфейса: все трансляторы C++ с заданной платформой одинаково осуществляют механизм вызова виртуальных функций. Действительно, это требование единообразия применимо только к классам, не имеющим элементов данных, а имеющим не более одного базового класса, который также не имеет элементов данных. Вот что означает это требование для следующего простого определения класса:
class calculator
{
public: virtual void add1(short x);
virtual void add2(short x, short y);
};
Все трансляторы с данной платформой должны создать эквивалентные последовательности машинного кода для следующего фрагмента программы пользователя:
extern calculator *pcalc;
pcalc->add1(1);
pcalc->add2(1, 2);
Отметим, что требуется не идентичность машинного кода на всех трансляторах, а его эквивалентность. Это означает, что каждый транслятор должен делать одинаковые допущения относительно того, как объект такого класса размещен в памяти и как его виртуальные функции динамически вызываются на этапе выполнения.
Впрочем, это не такое уж блестящее решение проблемы, как может показаться. Реализация виртуальных функций на C++ на этапе выполнения выливается в создание конструкций vptr и vtbl практически на всех трансляторах. При этой методике транслятор молча генерирует статический массив указателей функций для каждого класса, содержащего виртуальные функции. Этот массив называется vtbl (virtual function table – таблица виртуальных функций) и содержит один указатель функции для каждой виртуальной функции, определенной в данном классе или в ее базовом классе. Каждый объект класса содержит единственный невидимый элемент данных, именуемый vptr (virtual function pointer – указатель виртуальных функций); он автоматически инициализируется конструктором для указания на таблицу vtbl класса. Когда клиент вызывает виртуальную функцию, транслятор генерирует код, чтобы разыменовать указатель vptr , занести его в vtbl и вызвать функцию через ее указатель, найденный в назначенном месте. Так на C++ обеспечивается полиморфизм и диспетчеризация динамических вызовов. Рисунок 1.5 показывает представление на этапе выполнения массивов vptr/vtbl для класса calculator, рассмотренного выше.
Фактически каждый действующий в настоящее время качественный транслятор C++ использует базовые концепции vprt и vtbl. Существует два основных способа размещения таблицы vtbl: с помощью CFRONT и корректирующего переходника (adjuster thunk). Каждый из этих приемов имеет свой способ обращения с тонкостями множественного наследования. К счастью, на каждой из имеющихся платформ доминирует один из способов (трансляторы Win32 используют adjuster thunk, Solaris – стиль CFRONT для vtbl ). К тому же формат таблицы vtbl не влияет на исходный код C++, который пишет программист, а скорее является артефактом сгенерированного кода. Желающие узнать подробности об этих двух способах могут обратиться к прекрасной книге Стэна Липпмана «Объектная модель C++ изнутри» (Stan Lippman. Inside C++ Object Model).
Основываясь на столь далеко идущих допущениях, теперь можно решить проблему зависимости от транслятора. Предполагая, что все трансляторы на данной платформе одинаково реализуют механизм вызова виртуальной функции, можно определить класс интерфейса C++ так, чтобы глобальные операции над типами данных определялись в нем как виртуальные функции; тогда можно быть уверенным, что все трансляторы будут генерировать эквивалентный машинный код для вызова методов со стороны клиента. Это предположение об единообразии означает, что ни один класс интерфейса не имеет элементов данных и ни один класс интерфейса не может быть прямым потомком более чем одного класса интерфейса. Поскольку в классе интерфейса нет элементов данных, эти методы практически невозможно использовать.
Чтобы подчеркнуть это обстоятельство, полезно определить члены интерфейса как простые виртуальные функции, указав, что класс интерфейса задает только возможность вызова методов, а не их реализацию.
// ifaststring.h
class IFastString
{
public: virtual int Length(void) const = 0;
virtual int Find(const char *psz) const = 0;
};
Определение этих методов как чисто виртуальных также дает знать транслятору, что от класса интерфейса не требуется никакой реализации этих методов. Когда транслятор генерирует таблицу vtbl для класса интерфейса, входная точка для каждой простой виртуальной функции является или нулевой (null), или точкой входа в С-процедуру этапа выполнения (_purecall в Microsoft C++), которая при вызове генерирует логическое утверждение. Если бы метод не был определен как чисто виртуальный, транслятор попытался бы включить в соответствующую входную точку vtbl системную реализацию метода класса интерфейса, которая в действительности не существует. Это вызвало бы ошибку компоновки. Определенный таким образом класс интерфейса является абстрактным базовым классом. Соответствующий класс реализации должен порождаться классом интерфейса и перекрывать все чисто виртуальные фyнкции содержательными реализациями. Эта наследственная связь проявится в объектах, которые в качестве своего представления имеют двоичное надмножество представления класса интерфейса (которое как раз и есть vptr/vtbl). Дело в том, что отношение «является» («is-a») между порождаемым и базовым классами применяется на двоичном уровне в C++ так же, как и на уровне моделирования в объектно-ориентированной разработке:
class FastString : public IFastString
{
const int m_cch;
// count of characters
// число символов
char *m_psz;
public:
FastString(const char *psz);
~FastString(void);
int Length(void) const;
// returns # of characters
// возвращает число символов
int Find(const char *psz) const;
// returns offset
// возвращает смещение
};
Поскольку FastString порождается от IFastString, двоичное представление объектов FastString должно быть надмножеством двоичного представления IFastString. Это означает, что объекты FastString будут содержать указатель vptr, указывающий на совместимую с таблицей vtblIFastString. Поскольку классу FastString можно приписывать различные конкретные типы данных, его таблица vtbl будет содержать указатели на существующие реализации методов Length и Find. Их связь показана на рис. 1.6.
Даже несмотря на то, что открытые операторы над типами данных подняты до уровня чисто виртуальных функций в классе интерфейса, клиент не может приписывать значения объектам FastString, не имея определения класса для класса реализации. При демонстрации клиенту определения класса реализации от него будет скрыта двоичная инкапсуляция интерфейса; что не позволит клиенту использовать класс интерфейса. Одним из разумных способов обеспечить клиенту возможность использовать объекты FastString является экспорт из DLL глобальной функции, которая будет вызывать новый оператор от имени клиента. При условии, что эта подпрограмма экспортируется с опцией extern "С" , она будет доступна для любого транслятора C++.
// ifaststring.h
class IFastString {
public:
virtual int Length(void) const = 0;
virtual int Find(const char *psz) const = 0;
};
extern "C"
IFastString *CreateFastString(const char *psz);
// faststring.cpp (part of DLL)
// faststring.cpp (часть DLL)
IFastString *CreateFastString (const char *psz)
{ return new FastString(psz); }
Как было в случае класса-дескриптора, новый оператор вызывается исключительно внутри DLL FastString, а это означает, что размер и расположение объекта будут установлены с использованием того же транслятора, который транслировал все методы реализации.
Последнее препятствие, которое предстоит преодолеть, относится к уничтожению объекта. Следующая клиентская программа пройдет трансляцию, но результаты будут непредсказуемыми:
int f(void)
{
IFastString *pfs = CreateFastString(«Deface me»);
int n = pfs->Find(«ace me»);
delete pfs;
return n;
}
Непредсказуемое поведение вызвано тем фактом, что деструктор класса интерфейса не является виртуальным. Это означает, что вызов оператора delete не сможет динамически найти последний порожденный деструктор и рекурсивно уничтожит объект ближайшего внешнего типа по отношению к базовому типу. Поскольку деструктор FastString никогда не вызывается, в данном примере из буфера исчезнет строка «Deface me», которая должна там присутствовать.
Очевидное решение этой проблемы – сделать деструктор виртуальным в классе интерфейса. К сожалению, это нарушит независимость класса интерфейса от транслятора, так как положение виртуального деструктора в таблице vtbl может изменяться от транслятора к транслятору. Одним из конструктивных решений этой проблемы является добавление к интерфейсу явного метода Delete как еще одной чисто виртуальной функции, чтобы заставить производный класс уничтожать самого себя в своей реализации этого метода. В результате этого будет выполнен нужный деструктор. Модифицированная версия заголовочного файла интерфейса выглядит так:
// ifaststring.h
class IFastString
{
public:
virtual void Delete(void) = 0;
virtual int Length(void) const = 0;
virtual int Find(const char *psz) const = 0;
};
extern "C"
IFastString *CreateFastString (const char *psz);
она влечет за собой соответствующее определение класса реализации:
// faststring.h
#include «ifaststring.h»
class FastString : public IFastString
{ const int mcch;
// count of characters
// счетчик символов
char *mpsz; public: FastString(const char *psz);
~FastString(void);
void Delete(void);
// deletes this instance
// уничтожает этот экземпляр
int Length(void) const;
// returns # of characters
// возвращает число символов
int Find(const char *psz) const;
// returns offset
// возвращает смещение
};
// faststring.cpp
#include
#include «faststring.h»
IFastString* CreateFastString (const char *psz) {
return new FastString(psz);
}
FastString::FastString(const char *psz) : mcch(strlen(psz)), mpsz(new char[mcch + 1]) {
strcpy(mpsz, psz);
}
void FastString::Delete(void) {
delete this;
}
FastString::~FastString(void) {
delete[] mpsz;
}
int FastString::Lengtn(void) const {
return mcch;
}
int FastString::Find(const char *psz) const {
// O(1) lookup code deleted for clarity
// код поиска 0(1) уничтожен для ясности
}
Рисунок 1.7 показывает представление FastString на этапе выполнения. Чтобы использовать тип данных FastString, клиентам надо просто включить в программу файл определения интерфейса и вызвать CreateFastString:
#include «ifaststring.h»
int f(void)
{ int n = -1;
IFastString *pfs = CreateFastString(«Hi Bob!»);
if (pfs) { n = pfs->Find(«ob»);
pfs->Delete(); }
return n; }
Отметим, что все, кроме одной, точки входа в DLL FastString являются виртуальными функциями. Виртуальные функции класса интерфейса всегда вызываются косвенно, через указатель функции, хранящийся в таблице vtbl , избавляя клиента от необходимости указывать их символические имена на этапе разработки. Это означает, что методы интерфейса защищены от различий в коррекции символических имен на разных трансляторах. Единственная точка входа, которая явно компонуется по имени, – это CreateFastString – глобальная функция, которая обеспечивает клиенту доступ в мир FastString. Заметим, однако, что эта функция была экспортирована с опцией extern "С", которая подавляет коррекцию символов. Следовательно, все трансляторы C++ ожидают, что импортируемая библиотека и DLL экспортируют один и тот же идентификатор. Полезным результатом этой методики является то, что вы можете спокойно извлечь класс из DLL, использующей одну среду C++, а обратиться к этому классу из любой другой среды C++. Эта возможность необходима при построении основы для независимых от разработчика компонентов повторного пользования.
Полиморфизм на этапе выполнения
Управление реализациями классов с использованием абстрактных базовых классов как интерфейсов открывает целый мир новых возможностей в терминах того, что может случиться на этапе выполнения. Напомним, что DLL FastString экспортирует только один идентификатор – CreateFastString. Теперь пользователю легко динамически загрузить DLL, используя по требованию LoadLibrary, и разрешить этой единственной точке входа использовать GetProcAddress:
IFastString *CallCreateFastString(const char *psz)
{
static IFastString * (*pfn)(const char *) = 0;
if (!pfn) {
// init ptr 1st time through
// первое появление ptr
const TCHAR szDll[] = TEXT(«FastString.DLL»);
const char szFn[] = «CreateFastString»;
HINSTANCE h = LoadLibrary(szDll);
if (h) *(FARPROC*)&pfn = GetProcAddress(h, szFn); }
return pfn ? pfn(psz) : 0;
}
Эта методика имеет несколько возможных приложений. Одна из причин ее использования – предотвращение ошибок, генерируемых операционной системой при работе на машине, где не установлена реализация объектов. Приложения, использующие дополнительные системные компоненты, такие как WinSock или MAPI, используют похожую технику для запуска приложений на машинах с минимальной конфигурацией. Поскольку клиенту никогда не нужно компоновать импортируемую библиотеку DLL, он не зависит от загрузки DLL и может работать на машинах, на которых DLL вообще не установлена. Другой причиной для использования этой методики может быть медленная инициализация адресного пространства. Кроме того, DLL не загружается автоматически во время инициализации; и если в действительности реализация объекта не используется, то DLL не загрузится никогда. Другими преимуществами этого способа являются ускорение запуска клиента и сохранение адресного пространства для длительных процессов, которые могут никогда реально не использовать DLL.
Возможно, одним из наиболее интересных применений этой методики является возможность для клиента динамически выбирать между различными реализациями одного и того же интерфейса. Если описание интерфейса IFastString дано как общедоступное (publicly available), то ничто не препятствует как исходному конструктору (implementor) FastString, так и любым сторонним средствам реализации порождать дополнительные классы реализации от того же самого интерфейса. Подобно исходной реализации класса FastString, эти новые реализации будут иметь такое двоичное представление, что будут совместимы на двоичном уровне с исходным классом интерфейса. Все, что должен сделать пользователь, чтобы добиться полностью совместимых («plug-compatible») реализаций, – это определить правильное имя файла для желаемой реализации DLL.
Чтобы понять, как применить эту методику, предположим, что исходная реализация IFastString выполняла поиск слева направо. Это прекрасно для языков, анализируемых слева направо (например, английский, французский, немецкий). Для языков, анализируемых справа налево, предпочтительней вторая реализация IFastString, осуществляющая поиск справа налево. Эта альтернативная реализация может быть построена как вторая DLL с характерным именем (например, FastStringRL.DLL). Пусть обе DLL установлены на машине конечного пользователя, тогда он может выбрать нужный вариант IFastString простой загрузкой требуемой DLL на этапе выполнения:
IFastString * CallCreateFastString(const char *psz, bool bLeftToRight = true)
{
static IFastString * (*pfnlr)(const char *) = 0;
static IFastString * (*pfnrl)(const char *) = 0;
IFastString *(**ppfn) (const char *) = &pfnlr;
const TCHAR *pszDll = TEXT(«FastString.DLL»);
if (!bLeftToRight) { pszDll = TEXT(«FastStringRL.DLL»);
ppfn = &pfnrl; }
if (!(*ppfn)) {
// init ptr 1st time through
// первое появление ptr
const char szFn[] = «CreateFastString»;
HINSTANCE h = LoadLibrary(pszDll);
if (h) *(FARPROC*)ppfn = GetProcAddress(h, szFn); }
return (*ppfn) ? (*ppfn)(psz) : 0;
}
Когда клиент вызывает функцию без второго параметра,
pfs = CallCreateFastString(«Hi Bob!»);
n = pfs->Find(«ob»);
то загружается исходная DLL FastString, и поиск идет слева направо. Если же клиент указывает, что строка написана на разговорном языке, анализируемом справа налево:
pfs = CallCreateFastString(«Hi Bob!», false);
n = pfs->Find(«ob»);
то загружается альтернативная версия DLL (FastStringRL.DLL ), и поиск будет начинаться с крайней правой позиции строки. Главное здесь то, что вызывающие операторы CallCreateFastString не заботятся о том, какая из DLL используется для реализации методов объекта. Существенно лишь то, что указатель на совместимый с IFastString vptr возвращается функцией и что vptr обеспечивает успешное и семантически корректное функционирование. Эта форма полиморфизма на этапе выполнения чрезвычайно полезна при создании системы, динамически скомпонованной из двоичных компонентов.
Расширяемость объекта
Описанные до сих пор методики позволяют клиентам выбирать и динамически загружать двоичные компоненты, что дает возможность изменять с течением времени двоичное представление их реализации без необходимости повторной трансляции клиента. Это само по себе чрезвычайно полезно при построении динамически компонуемых систем. Существует, однако, один аспект объекта, который не может изменяться во времени, – это его интерфейс. Это связано с тем, что пользователь осуществляет трансляцию с определенной сигнатурой класса интерфейса, и любые изменения в описании интерфейса требуют повторной трансляции клиента для учета этих изменений. Хуже того, изменение описания интерфейса полностью нарушает инкапсуляцию объекта (так как его открытый интерфейс изменился) и может испортить программы всех существующих клиентов. Даже самое безобидное изменение, такое как изменение семантики метода с сохранением его сигнатуры, делает бесполезной всю установленную клиентскую базу. Это означает, что интерфейсы являются постоянными двоичными и семантическими контрактами (contracts), которые никогда не должны изменяться. Эта неизменяемость требует стабильной и предсказуемой среды на этапе выполнения.
Несмотря на неизменяемость интерфейсов, часто возникает необходимость добавить дополнительные функциональные возможности, которые не могли быть предусмотрены в период первоначального составления интерфейса. Хотелось бы, например, использовать знание двоичного представления таблицы vtbl и просто добавлять новые методы в конец существующего описания интерфейса. Рассмотрим исходную версию IFastString:
class IFastString {
public:
virtual void Delete(void) = 0;
virtual int Length(void) = 0;
virtual int Find(const char *psz) = 0;
};
Простое изменение класса интерфейса путем объявлений добавочных виртуальных функций после объявлений существующих методов имело бы следствием двоичный формат таблицы vtbl, который является надмножеством исходной версии по мере того, как появятся какие-либо новые элементы vtbl после тех, которые соответствуют исходным методам. У реализации объектов, которые транслируются с новым описанием интерфейса, все новые методы будут добавляться к исходному размещению vtbl:
class IFastString {
public:
// faux version 1.0
// фиктивная версия 1.0
virtual void Delete(void) = 0;
virtual int Length(void) = 0;
virtual int Find(const char *psz) = 0;
// faux version 2.0
// фиктивная версия 2.0
virtual int FindN(const char *psz, int n) = 0;
};
Это решение почти работает. Те клиенты, у которых оттранслирована исходная версия интерфейса, остаются в счастливом неведении относительно всех составляющих таблицы vtbl, кроме первых трех. Когда старые клиенты получают обновленные объекты, имеющие в vtbl вход для FindN, они продолжают нормально работать. Проблема возникает, когда новым клиентам, ожидающим, что IFastString имеет четыре метода, случится столкнуться с устаревшими объектами, где метод FindN не реализуется. Когда клиент вызовет FindN на объект, странслированный с исходным описанием интерфейса, результаты будут вполне определенными. Программа прервет работу.
В этой методике проблема заключается в том, что она нарушает инкапсуляцию объекта, изменяя открытый интерфейс. Подобно тому, как изменение открытого интерфейса в классе C++ может вызвать ошибки на этапе трансляции, когда происходит перестройка клиентского кода, так и изменение двоичного описания интерфейса вызовет ошибки на этапе выполнения, когда клиентская программа перезапущена. Это означает, что интерфейсы должны быть неизменяемыми с момента первой редакции. Решение этой проблемы заключается в том, чтобы разрешить классу реализации выставлять более чем один интерфейс. Этого можно достигнуть, если предусмотреть, что один интерфейс порождается от другого, связанного с ним интерфейса. А можно сделать так, чтобы класс реализации наследовал от нескольких несвязанных классов интерфейса. В любом случае клиент мог бы использовать имеющуюся в C++ возможность определения типа на этапе выполнения – идентификацию Runtime Type Identification – RTTI, чтобы динамически опросить объект и убедиться в том, что его требуемая функциональность действительно поддерживается уже работающим объектом.
Рассмотрим простой случай интерфейса, расширяющего другой интерфейс. Чтобы добавить в IFastString операцию FindN, позволяющую находить n–е вхождение подстроки, необходимо породить второй интерфейс от IFastString и добавить в него новое описание метода:
class IFastString2 : public IFastString {
public: // real version 2.0
// настоящая версия 2.0
virtual int FindN(const char *psz, int n) = 0;
};
Клиенты могут с уверенностью динамически опрашивать объект с помощью оператора C++ dynamic_cast, чтобы определить, является ли он совместимым с IFastString2
int Find10thBob(IFastString *pfs) {
IFastString2 *pfs2 = dynamic_cast(pfs);
if(pfs2)
// the object derives from IFastString2
// объект порожден от IFastString2
return pfs2->FindN(«Bob», 10);
else {
// object doesn't derive from IFastString2
// объект не порожден от IFastString2
error(«Cannot find 10th occurrence of Bob»);
return -1;
}
Если объект порожден от расширенного интерфейса, то оператор dynamic_cast возвращает указатель на вариант объекта, совместимый с IFastString2, и клиент может вызвать расширенный метод объекта. Если же объект не порожден от расширенного интерфейса, то оператор dynamic_cast возвратит пустой (null) указатель. В этом случае клиент может или выбрать другой способ реализации, зарегистрировав сообщение об ошибке, или молча продолжить без расширенной операции. Эта способность назначенного клиентом постепенного сокращения возможностей очень важна при создании гибких динамических систем, которые могут обеспечить со временем расширенные функциональные возможности.
Иногда требуется раскрыть еще один аспект функциональности объекта, тогда разворачивается еще более интересный сценарий. Обсудим, что следует предпринять, чтобы добавить постоянства, или персистентности (persistence), классу реализации IFastString. Хотя, вероятно, можно добавить методы Load и Save к расширенной версии IFastString, другие типы объектов, не совместимые с IFastString, могут тоже быть постоянными. Простое создание нового интерфейса, который расширяет IFastString: