
- •Глава 6 посвящена понятию производных классов, которое позволяет строить
- •Раздел 3.4 главы 2. Для обозначения справочного руководства применяется
- •1991 Г.Г. (такие как множественное наследование, статические функции-члены
- •1.1 Введение
- •1.2 Парадигмы программирования
- •1.2.1 Процедурное программирование
- •1.2.5 Объектно-ориентированное программирование
- •1.5 Поддержка объектно-ориентированного программирования
- •1.5.1 Механизм вызова
- •1.5.2 Проверка типа
- •1.5.3 Множественное наследование
- •1.6 Пределы совершенства
- •2.2 Имена
- •2.3.2 Неявное преобразование типа
- •2.4 Литералы
- •2.4.4 Строки
- •2.6. Экономия памяти
- •2.6.1 Поля
- •3.1.1 Анализатор
- •3.1.2 Функция ввода
- •3.2 Сводка операций
- •3.2.3 Инкремент и декремент
- •3.2.5 Преобразование типа
- •3.2.6 Свободная память
- •3.3.2 Оператор goto
- •4.1 Введение
- •4.3.1 Единственный заголовочный файл
- •4.3.2 Множественные заголовочные файлы
- •4.4 Связывание с программами на других языках
- •4.6.3 Передача параметров
- •5.1 Введение и краткий обзор
- •5.3.1 Альтернативные реализации
- •5.3.2 Законченный пример класса
- •Vector и matrix, мы могли бы обойтись без контроля индекса при
- •5.4.5 Указатели на члены
- •5.4.6 Структуры и объединения
- •5.5.3 Свободная память
- •5.5.5 Массивы объектов класса
- •6.1 Введение и краткий обзор
- •6.2.3 Иерархия классов
- •6.2.4 Поля типа
- •6.2.5 Виртуальные функции
- •6.4.1 Монитор экрана
- •6.5 Множественное наследование
- •7.1 Введение
- •7.3 Пользовательские операции преобразования типа
- •7.3.2 Операции преобразования
- •7.3.3 Неоднозначности
- •7.5 Большие объекты
- •Void f2(t a) // вариант с контролем
- •Void f3(t a) // вариант с контролем
- •Inv() обращает саму матрицу m, а не возвращает новую, обратную m,
- •7.13 Предостережения
- •8.1 Введение
- •8.4.4 Неявная передача операций
- •8.4.5 Введение операций с помощью параметров шаблонного класса
- •8.7.1 Задание реализации с помощью параметров шаблона
- •9.1 Обработка ошибок
- •9.1.2 Другие точки зрения на особые ситуации
- •9.3.2 Производные особые ситуации
- •9.4.2 Предостережения
- •9.4.3 Исчерпание ресурса
- •9.4.4 Особые ситуации и конструкторы
- •9.5 Особые ситуации могут не быть ошибками
- •10.1 Введение
- •10.2 Вывод
- •10.2.1 Вывод встроенных типов
- •10.4.1.2 Поля вывода
- •10.4.1.4 Вывод целых
- •Istream - шаблон типа smanip, а smanip - двойник для ioss.
- •10.5.1 Закрытие потоков
- •10.5.2 Строковые потоки
- •X Целый параметр выдается в шестнадцатеричной записи;
- •11.1 Введение
- •11.2 Цели и средства
- •11.3 Процесс развития
- •11.3.1 Цикл развития
- •11.3.2 Цели проектирования
- •11.3.3 Шаги проектирования
- •11.3.3.1 Шаг 1: определение классов
- •11.3.3.2 Шаг 2: определение набора операций
- •11.3.3.3 Шаг 3: указание зависимостей
- •11.3.3.4 Шаг 4: определение интерфейсов
- •11.3.3.5 Перестройка иерархии классов
- •11.3.3.6 Использование моделей
- •11.3.4 Эксперимент и анализ
- •11.3.5 Тестирование
- •11.3.6 Сопровождение
- •11.3.7 Эффективность
- •11.4 Управление проектом
- •11.4.1 Повторное использование
- •11.4.2 Размер
- •11.4.3 Человеческий фактор
- •11.5 Свод правил
- •11.6 Список литературы с комментариями
- •12.1 Проектирование и язык программирования.
- •12.1.1 Игнорирование классов
- •12.1.2 Игнорирование наследования
- •12.1.3 Игнорирование статического контроля типов
- •12.1.4 Гибридный проект
- •12.2 Классы
- •12.2.1 Что представляют классы?
- •12.2.2 Иерархии классов
- •12.2.3 Зависимости в рамках иерархии классов.
- •Vertical_scrollbar или с помощью одного типа scrollbar, который
- •12.2.6 Отношения использования
- •12.2.7 Отношения внутри класса
- •12.3 Компоненты
- •12.4 Интерфейсы и реализации
- •12.5 Свод правил
- •13.1 Введение
- •13.2 Конкретные типы
- •13.4 Узловые классы
- •1, 2, 6 И 7. Класс, который не удовлетворяет условию 6, походит
- •13.5.1 Информация о типе
- •13.6 Обширный интерфейс
- •13.7 Каркас области приложения
- •13.8 Интерфейсные классы
- •13.10 Управление памятью
12.2.6 Отношения использования
Для составления и понимания проекта часто необходимо знать,
какие классы и каким способом использует данный класс.
Такие отношения классов
на С++ выражаются неявно. Класс может использовать только те
имена, которые где-то определены, но нет такой части в программе
на С++, которая содержала бы список всех используемых имен.
Для получения такого списка необходимы
вспомогательные средства (или, при их отсутствии, внимательное
чтение). Можно следующим образом классифицировать те способы,
с помощью которых класс X может использовать класс Y:
- X использует имя Y
- X использует Y
- X вызывает функцию-член Y
- X читает член Y
- X пишет в член Y
- X создает Y
- X размещает auto или static переменную из Y
- X создает Y с помощью new
- X использует размер Y
Мы отнесли использование размера объекта к его созданию, поскольку
для этого требуется знание полного определения класса. С другой
стороны, мы выделили в отдельное отношение использование имени Y,
поскольку, указывая его в описании Y* или в описании
внешней функции, мы вовсе не нуждаемся в доступе к определению Y:
class Y; // Y - имя класса
Y* p;
extern Y f(const Y&);
Мы отделили создание Y с помощью new от случая описания
переменной, поскольку возможна такая реализация С++, при которой
для создания Y с помощью new необязательно знать
размер Y. Это может быть существенно для ограничения всех зависимостей
в проекте и сведения к минимуму перетрансляции после внесения изменений.
Язык С++ не требует, чтобы создатель классов точно определял,
какие классы и как он будет использовать. Одна из причин этого
заключена в том, что самые важные классы зависят от столь большого
количества других классов, что для придания лучшего вида программе
нужна сокращенная форма записи списка используемых классов, например,
с помощью команды #include. Другая причина в том, что классификация
этих зависимостей и, в частности, обЪединение некоторых зависимостей
не является обязанностью языка программирования. Наоборот, цели
разработчика, программиста или вспомогательного средства определяют то,
как именно следует рассматривать отношения использования. Наконец, то,
какие зависимости представляют больший интерес, может зависеть от
специфики реализации языка.
12.2.7 Отношения внутри класса
До сих пор мы обсуждали только классы, и хотя операции упоминались,
если не считать обсуждения шагов процесса развития программного
обеспечения ($$11.3.3.2), то они были на втором плане, объекты же
практически вообще не упоминались. Понять это просто: в С++
класс, а не функция или объект, является основным понятием
организации системы.
Класс может скрывать в себе всякую специфику реализации,
наравне с "грязными" приемами программирования, а иногда он
вынужден это делать. В то же время объекты большинства классов
сами образуют регулярную структуру и используются такими способами,
что их достаточно просто описать. Объект класса может быть
совокупностью других вложенных объектов (их часто называют членами),
многие из которых, в свою очередь, являются указателями или ссылками
на другие объекты. Поэтому отдельный объект можно рассматривать как
корень дерева объектов, а все входящие в него объекты как "иерархию
объектов", которая дополняет иерархию классов, рассмотренную в $$12.2.4.
Рассмотрим в качестве примера класс строк из $$7.6:
class String {
int sz;
char* p;
public:
String(const char* q);
~String();
//...
};
Объект типа String можно изобразить так:
12.2.7.1 Инварианты
Значение членов или объектов, доступных с помощью членов класса,
называется состоянием объекта (или просто значением объекта).
Главное при построении класса - это: привести объект в полностью
определенное состояние (инициализация), сохранять полностью определенное
состояние обЪекта в процессе выполнения над ним различных операций,
и в конце работы уничтожить объект без всяких последствий. Свойство,
которое делает состояние объекта полностью определенным, называется
инвариантом.
Поэтому назначение инициализации - задать конкретные значения,
при которых выполняется инвариант объекта. Для каждой операции класса
предполагается, что инвариант должен иметь место перед выполнением
операции и должен сохраниться после операции. В конце работы
деструктор нарушает инвариант, уничтожая объект. Например,
конструктор 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, что две самые общие формы преобразования
иерархии классов состоят в разбиении класса на два и в выделении
общей части двух классов в базовый класс. В обоих случаях хорошо
продуманный инвариант может подсказать возможность такого
преобразования. Если, сравнивая инвариант с программами операций,
можно обнаружить, что большинство проверок инварианта излишни,
то значит класс созрел для разбиения. В этом случае подмножество операций
имеет доступ только к подмножеству состояний объекта. Обратно,
классы созрели для слияния, если у них сходные инварианты, даже
при некотором различии в их реализации.
12.2.7.2 Инкапсуляция
Отметим, что в С++ класс, а не отдельный объект, является той
единицей, которая должна быть инкапсулирована (заключена в оболочку).
Например:
class list {
list* next;
public:
int on(list*);
};
int list::on(list* p)
{
list* q = this;
for(;;) {
if (p == q) return 1;
if (q == 0) return 0;
q = q->next;
}
}
Здесь обращение к частному указателю list::next допустимо, поскольку
list::on() имеет доступ ко всякому объекту класса list, на который
у него есть ссылка. Если это неудобно, ситуацию можно упростить,
отказавшись от возможности доступа через функцию-член к
представлениям других объектов, например:
int list::on(list* p)
{
if (p == this) return 1;
if (p == 0) return 0;
return next->on(p);
}
Но теперь итерация превращается в рекурсию, что может сильно
замедлить выполнение программы, если только транслятор
не сумеет обратно преобразовать рекурсию в итерацию.
12.2.8 Программируемые отношения
Конкретный язык программирования не может прямо поддерживать
любое понятие любого метода проектирования. Если язык программирования
не способен прямо представить понятие проектирования, следует
установить удобное отображение конструкций, используемых в проекте,
на языковые конструкции. Например, метод проектирования может
использовать понятие делегирования, означающее, что всякая
операция, которая не определена для класса A, должна выполняться
в нем с помощью указателя p на соответствующий член класса B,
в котором она определена. На С++ нельзя выразить это прямо. Однако,
реализация этого понятия настолько в духе С++, что легко представить
программу реализации:
class A {
B* p;
//...
void f();
void ff();
};
class B {
//...
void f();
void g();
void h();
};
Тот факт, что В делегирует A с помощью указателя A::p,
выражается в следующей записи:
class A {
B* p; // делегирование с помощью p
//...
void f();
void ff();
void g() { p->g(); } // делегирование q()
void h() { p->h(); } // делегирование h()
};
Для программиста совершенно очевидно, что здесь происходит, однако здесь
явно нарушается принцип взаимнооднозначного соответствия. Такие
"программируемые" отношения трудно выразить на языках программирования,
и поэтому к ним трудно применять различные вспомогательные средства.
Например, такое средство может не отличить "делегирование" от B
к A с помощью A::p от любого другого использования B*.
Все-таки следует всюду, где это возможно, добиваться
взаимнооднозначного соответствия между понятиями проекта и понятиями
языка программирования. Оно дает определенную простоту и гарантирует,
что проект адекватно отображается в программе, что упрощает
работу программиста и вспомогательных средств.
Операции преобразований типа являются механизмом, с помощью которого
можно представить в языке класс программируемых отношений, а именно:
операция преобразования X::operator Y() гарантирует, что всюду,
где допустимо использование Y, можно применять и X. Такое же
отношение задает конструктор Y::Y(X). Отметим, что операция
преобразования типа (как и конструктор) скорее создает новый объект,
чем изменяет тип существующего объекта. Задать операцию преобразования
к функции Y - означает просто потребовать неявного применения
функции, возвращающей Y. Поскольку неявные применения операций
преобразования типа и операций, определяемых конструкторами, могут
привести к неприятностям, полезно проанализировать их в отдельности
еще в проекте.
Важно убедиться, что граф применений операций преобразования типа
не содержит циклов. Если они есть, возникает двусмысленная ситуация,
при которой типы, участвующие в циклах, становятся несовместимыми в
комбинации. Например:
class Big_int {
//...
friend Big_int operator+(Big_int,Big_int);
//...
operator Rational();
//...
};
class Rational {
//...
friend Rational operator+(Rational,Rational);
//...
operator Big_int();
};
Типы Rational и Big_int не так гладко взаимодействуют, как можно
было бы подумать:
void f(Rational r, Big_int i)
{
//...
g(r+i); // ошибка, неоднозначность:
// operator+(r,Rational(i)) или
// operator+(Big_int(r),i)
g(r,Rational(i)); // явное разрешение неопределенности
g(Big_int(r),i); // еще одно
}
Можно было бы избежать таких "взаимных" преобразований, сделав
некоторые из них явными. Например, преобразование Big_int к типу
Rational можно было бы задать явно с помощью функции make_Rational()
вместо операции преобразования, тогда сложение в приведенном
примере разрешалось бы как g(BIg_int(r),i). Если нельзя избежать
"взаимных" операций преобразования типов, то нужно преодолевать
возникающие столкновения или с помощью явных преобразований (как было
показано), или с помощью определения нескольких различных версий
бинарной операции (в нашем случае +).