|
Значение членов или объектов, доступных с помощью членов класса,
называется состоянием объекта (или просто значением объекта).
Главное при построении класса - это: привести объект в полностью
определенное состояние (инициализация), сохранять полностью определенное
состояние обЪекта в процессе выполнения над ним различных операций,
и в конце работы уничтожить объект без всяких последствий. Свойство,
которое делает состояние объекта полностью определенным, называется
инвариантом.
Поэтому назначение инициализации - задать конкретные значения,
при которых выполняется инвариант объекта. Для каждой операции класса
предполагается, что инвариант должен иметь место перед выполнением
операции и должен сохраниться после операции. В конце работы
деструктор нарушает инвариант, уничтожая объект. Например,
конструктор String::String(const char*) гарантирует,
что p указывает на массив из, по крайней мере, sz элементов, причем
sz имеет осмысленное значение и v[sz-1]==0. Любая строковая операция
не должна нарушать это утверждение.
При проектировании класса требуется большое искусство, чтобы
сделать реализацию класса достаточно простой и допускающей
наличие полезных инвариантов, которые несложно задать. Легко
требовать, чтобы класс имел инвариант, труднее предложить полезный
инвариант, который понятен и не накладывает жестких ограничений
на действия разработчика класса или на эффективность реализации.
Здесь "инвариант" понимается как программный фрагмент,
выполнив который, можно проверить состояние объекта. Вполне возможно
дать более строгое и даже математическое определение инварианта, и в
некоторых ситуациях оно может оказаться более подходящим. Здесь же
под инвариантом понимается практическая, а значит, обычно экономная,
но неполная проверка состояния объекта.
Понятие инварианта появилось в работах Флойда, Наура и Хора,
посвященных пред- и пост-условиям, оно встречается во всех важных
статьях по абстрактным типам данных и верификации программ за
последние 20 лет. Оно же является основным предметом отладки в C++.
Обычно, в течение работы функции-члена инвариант не сохраняется.
Поэтому функции, которые могут вызываться в те моменты, когда
инвариант не действует, не должны входить в общий интерфейс класса.
Такие функции должны быть частными или защищенными.
Как можно выразить инвариант в программе на С++? Простое решение -
определить функцию, проверяющую инвариант, и вставить вызовы этой
функции в общие операции. Например:
class String { int sz; int* p; public: class Range {}; class Invariant {}; void check(); String(const char* q); ~String(); char& operator[](int i); int size() { return sz; } //... }; void String::check() { if (p==0 || sz<0 || TOO_LARGE<=sz || p[sz-1]) throw Invariant; } char& String::operator[](int i) { check(); // проверка на входе if (i<0 || i<sz) throw Range; // действует check(); // проверка на выходе return v[i]; }
Этот вариант прекрасно работает и не осложняет жизнь программиста. Но для такого простого класса как String проверка инварианта будет занимать большую часть времени счета. Поэтому программисты обычно выполняют проверку инварианта только при отладке:
inline void String::check() { if (!NDEBUG) if (p==0 || sz<0 || TOO_LARGE<=sz || p[sz]) throw Invariant; }
Мы выбрали имя NDEBUG, поскольку это макроопределение, которое используется для аналогичных целей в стандартном макроопределении С assert(). Традиционно NDEBUG устанавливается с целью указать, что отладки нет. Указав, что check() является подстановкой, мы гарантировали, что никакая программа не будет создана, пока константа NDEBUG не будет установлена в значение, обозначающее отладку. С помощью шаблона типа Assert() можно задать менее регулярные утверждения, например:
template<class T, class X> inline void Assert(T expr,X x) { if (!NDEBUG) if (!expr) throw x; }
вызовет особую ситуацию x, если expr ложно, и мы не отключили проверку с помощью NDEBUG. Использовать Assert() можно так:
class Bad_f_arg { }; void f(String& s, int i) { Assert(0<=i && i<s.size(),Bad_f_arg()); //... }
Шаблон типа Assert() подражает макрокоманде assert() языка С.
Если i не находится в требуемом диапазоне, возникает особая
ситуация Bad_f_arg.
С помощью отдельной константы или константы из класса проверить
подобные утверждения или инварианты - пустяковое дело. Если же
необходимо проверить инварианты с помощью объекта, можно определить
производный класс, в котором проверяются операциями из класса, где нет
проверки, см. упр.8 в $$13.11.
Для классов с более сложными операциями расходы на проверки могут
быть значительны, поэтому проверки можно оставить только для "поимки"
трудно обнаруживаемых ошибок. Обычно полезно оставлять по крайней
мере несколько проверок даже в очень хорошо отлаженной программе.
При всех условиях сам факт определения инвариантов и использования
их при отладке дает неоценимую помощь для получения правильной
программы и, что более важно, делает понятия, представленные
классами, более регулярными и строго определенными. Дело в том, что
когда вы создаете инварианты, то рассматриваете класс с другой
точки зрения и вносите определенную избыточность в программу.
То и другое увеличивает вероятность обнаружения ошибок, противоречий
и недосмотров.
Мы указали в $$11.3.3.5, что две самые общие формы преобразования
иерархии классов состоят в разбиении класса на два и в выделении
общей части двух классов в базовый класс. В обоих случаях хорошо
продуманный инвариант может подсказать возможность такого
преобразования. Если, сравнивая инвариант с программами операций,
можно обнаружить, что большинство проверок инварианта излишни,
то значит класс созрел для разбиения. В этом случае подмножество операций
имеет доступ только к подмножеству состояний объекта. Обратно,
классы созрели для слияния, если у них сходные инварианты, даже
при некотором различии в их реализации.