Жанр: Учеба
Правила программирования на Си и Си плюс плюс.
... разными значениями. Если имеется всего один node, то какой
из конструкторов выполнит эту инициализацию? Ответ неприятный.
Инициализировать node должен наследуемый последним производный
класс (tree_list_node). Хотя это действительно плохая мысль -
требовать, чтобы класс знал о чем-либо в иерархии, кроме своих
непосредственных родителей - иначе было бы слишком сильное
внутреннее связывание.
Обратная сторона той же самой проблемы проявляется, если у вас есть
виртуальные функции как в следующем коде:
class persistent
{
public
:
virtual flush() = 0;
};
class doc1: virtual public persistent
{
public
:
virtual flush() { /* сохранить данные doc1 на диске */ }
};
class doc2: virtual public persistent
{
public
:
virtual flush() { /* сохранить данные doc2 на диске */ }
Вопросы проект ирования и реализации 151
};
class superdoc : public doc1, public doc2 {};
persistent *p = new superdoc();
p-"flush(); // ОШИБКА: какая из функций flush() вызвана?
102. Смешения не должны наследоваться от чего
попало
103. Смешения должны быть виртуальными
базовыми классами
104. Инициализируйте виртуальные базовые классы
при помощи конструктора, используемого по
умолчанию
Вы можете свести до минимума рассмотренные ранее проблемы, стараясь
придерживаться следующих правил (многие смешения не могут
соответствовать им всем, но вы делайте все от вас зависящее):
+ Если можно, то смешения не должны наследоваться от чего попало,
тем самым полностью устраняя проблему ромбовидной иерархии при
множественном наследовании.
+ Для смешения должна обеспечиваться возможность быть виртуальным
базовым классом для того, чтобы не возникала проблема
неопределенности в случае, если у вас все же получилась ромбовидная
структура классов.
+ Если можно, то смешение должно всегда строиться с использованием
только конструктора по умолчанию (не имеющего аргументов). Это
упрощает оформление смешения в качестве виртуального базового
класса, потому что вам не нужно будет заботиться об инициализации
большей части наследуемого объекта. В конце концов, по умолчанию
всегда используется конструктор по умолчанию.
Правила программирования на Си++
152
105. Наследование не подходит, если вы никогда не
посылаете сообщения базового класса объекту
производного класса
106. Везде, где можно, предпочитайте включение
наследованию
107. Используйте закрытые базовые классы лишь
когда вы должны обеспечить виртуальные
замещения
Главная выгода от наследования состоит в том, что вы можете писать
универсальный код, манипулирующий объектами обобщенного базового
класса, и тот же самый код может также манипулировать объектами
производного класса (или точнее, может манипулировать компонентом
базового класса в объекте производного класса). Например, вы можете
написать функцию, которая печатает список объектов фигура, но этот
список на самом деле содержит объекты, которые унаследованы от
фигуры, такие как круг и линия. Тем не менее, функции печати этого
знать не нужно. Она вполне довольна, считая их обобщенными фигурами.
Это качество является тем, что имеют в виду, когда говорят о повторном
использовании кода. Вы повторно используете один и тот же код для
разных дел: временами он печатает круг, временами - линию.
Если вы обнаружили у себя объект производного класса, от которого
никогда не требуется использовать возможности базового класса, то,
вероятно, в проектировании иерархии есть какая-то ошибка, хотя
встречаются редкие случаи, когда такое поведение приемлемо; поэтому в
языке есть закрытые базовые классы. Но все же включение (назначение
объекта полем в классе, а не базовым классом) всегда лучше, чем
наследование (при условии, конечно, что у вас есть выбор).
Если объект производного класса никогда не получает сообщения
базового класса, то вероятнее всего компонент базового класса в объекте
производного класса действительно должен быть полем, и наследование
вовсе не должно использоваться. Вместо вот этого:
class derived : public base
{
};
вам почти всегда лучше делать так:
class derived
{
base base_obj;
Вопросы проект ирования и реализации 153
};
Используйте закрытые базовые классы лишь в случаях, когда вам нужно в
производном классе перегружать виртуальные функции базового класса.
Удачный пример подобного неправильного использования
наследования есть во многих иерархиях классов для Windows, которые
наследуют классы типа "диалоговое окно" от "окна". Однако в реальной
программе вы никогда не посылаете относящиеся к окну сообщения (типа
"сдвинуться" или "изменить размер") в диалоговое окно. То есть
диалоговое окно не является окном, по крайней мере, с точки зрения того,
как диалоговое окно используется в программе. Скорее диалоговое окно
использует окно, чтобы себя показать. Слово "является" подразумевает
наследование, а "использует" - включение, которое здесь лучше
подходит.
Подобное плохое проектирование, между прочим, обычно имеет
причиной отступление от правила определения объектов в первую
очередь. То есть концепция "окна" в Microsoft Windows имеет смысл
только для подсистемы визуального вывода. Диалоговое окно
изображается в виде окна, но это не значит, что это окно, даже если
подсистема визуального вывода предпочитает его рассматривать в этом
качестве. Плохое проектирование получается, когда исходят из
существующей системы визуального вывода и затем помещают вокруг
нее оболочку при помощи библиотеки классов, вместо того, чтобы
исходить из описания программы, решая затем, как реализовать в
программе реальные объекты.
108. Проектируйте структуры данных в последнюю
очередь
Добавление полей данных выполняется в процессе проектирования в
последнюю очередь. Другими словами, после того, как вы разработали
сообщения, вам нужно понять, как реализовать возможности,
запрашиваемые этими сообщениями. Вероятно, это труднейшая часть
процесса объектно-ориентированного проектирования для структурного
программиста: заставить себя не думать о лежащей в основе структуре
данных до тех пор, пока не будет готовы полностью система обмена
сообщениями и иерархия классов.
В этот момент процесса проектирования вы также добавляете
закрытые (private) "рабочие" (или "вспомогательные") функции,
которые помогают обработчикам сообщений справиться со своей
работой.
Правила программирования на Си++
109. Все данные в определении класса должны быть
закрытыми
110. Никогда не допускайте открытого доступа к
закрытым данным
Все данные в определении класса должны быть закрытыми. Точка.
Никаких исключений. Проблема здесь заключается в тесном сцеплении
между классом и его пользователями, если они имеют прямой доступ к
полям данных. Я приведу вам несколько примеров. Скажем, у вас есть
класс string, который использует массив типа char для хранения своих
данных. Спустя год к вам обращается заказчик из Пакистана, поэтому вам
нужно перевести все свои строки на урду, что вынуждает перейти на
Unicode. Если ваш строковый класс позволяет какой-либо доступ к
локальному буферу char*, или сделав это поле открытым (public), или
определив функцию, возвращающую char*, то вы в большой беде.
Взглянем на код. Вот действительно плохой проект:
class string
{
public
:
char *buf;
// ...
};
f()
{
string s;
// ...
printf("%s/n", s.buf );
}
Если вы попробуете изменить определение buf на wchar_t* для работы
с Unicode (что предписывается ANSI Си), то все функции, которые имели
прямой доступ к полю buf, перестают работать. И вы будете должны их
все переписывать.
Другие родственные проблемы проявляются во внутренней
согласованности. Если строковый объект содержит поле length, то вы
могли бы модифицировать буфер без модификации length, тем самым
разрушив эту строку. Аналогично, деструктор строки мог бы
предположить, что, так как конструктор разместил этот буфер
посредством new, то будет безопаснее передать указатель на buf
оператору delete. Однако если у вас прямой доступ, то вы могли бы
сделать что-нибудь типа:
Вопросы проект ирования и реализации 155
string s;
char array[128];
s.buf = array;
и организация памяти разрушается, когда эта строка покидает область
действия.
Простое закрытие при помощи модификатора private поля buf не
помогает, если вы продолжаете обеспечивать доступ посредством
функции. Листинг 7 показывает фрагмент простого определения строки,
которое будет использоваться мной несколько раз в оставшейся части
этой главы. (Упрощение, сделанное мной, свелось к помещению всего в
один листинг; обычно определение класса и встроенные функции будут в
заголовочном файле, а остальной код - в файле .cpp).
Листинг 7. Простой строковый класс
1 class string
2 {
3 char *buf;
4 int length; // длина буфера (не строки);
6 public
:
7 virtual
8 ~string( void );
9 string( const char *input_str = "" );
10 string( const string &r );
12 virtual const string &operator=( const string &r );
14 virtual int operator
" ( const string &r ) const;
15 virtual int operator
" ( const string &r ) const;
16 virtual int operator
==( const string &r ) const;
Посмотри в окно!
Чтобы сохранить великий дар природы — зрение,
врачи рекомендуют читать непрерывно не более 45–50 минут,
а потом делать перерыв для ослабления мышц глаза.
В перерывах между чтением полезны
гимнастические упражнения: переключение зрения с ближней точки на более дальнюю.
18 virtual void print( ostream &output ) const;
19 // ...
20 };
21 //----------------------------------------------------------------
22 inline string::string( const char *input_str /*= ""*/ )
23 {
24 length = strlen(input_str) + 1;
25 buf = new char[ length ];
26 strcpy( buf, input_str );
27 }
28 //----------------------------------------------------------------
29 inline string::string( const string &r )
30 {
31 length = r.length;
32 buf = new char[ length ];
33 strcpy( buf, r.buf );
34 }
35 //----------------------------------------------------------------
36 /* виртуальный */ string:: ~string( void )
Правила программирования на Си++
37 {
38 delete buf;
39 }
40 //----------------------------------------------------------------
41 /* виртуальный */ const string &string::operator=( const string &r)
42 {
43 if ( this != &r )
44 {
45 if
( length != r.length )
46 {
47 free( buf );
48 length = r.length;
49 buf = new char[ length ];
50 }
51 strcpy( buf, r.buf );
52 }
53 return *this;
54 }
56 //----------------------------------------------------------------
57 /* виртуальный */ int string::operator" ( const string &r ) const
58 {
59 return strcmp(buf, r.buf) " 0;
60 }
61 //----------------------------------------------------------------
62 /* виртуальный */ int string::operator" ( const string &r ) const
63 {
64 return strcmp(buf, r.buf) " 0;
65 }
66 //----------------------------------------------------------------
67 /* виртуальный */ int string::operator==( const string &r ) const
68 {
69 return strcmp(buf, r.buf) == 0;
70 }
71 //----------------------------------------------------------------
72 /* виртуальный */ void string::print( ostream &output ) const
73 {
74 cout "" buf;
75 }
76 //----------------------------------------------------------------
77 inline ostream &operator""( ostream &output, const string &s )
78 {
79 // Эта функция не является функцией-членом класса string,
80 // но не должна быть дружественной, потому что мной тут
81 // реализован метод вывода строкой своего значения.
83 s.print(output);
84 return output;
85 }
Вы заметите, что я умышленно не реализовал следующую функцию в
листинге 7:
string::operator const char*() { return buf; }
Вопросы проект ирования и реализации 157
Если бы реализовал, то мог бы сделать следующее:
void f( void )
{
string s;
// ...
printf("%s\n", (const char*)s );
}
но я не cмогу реализовать функцию operator char*(), которая бы
работала со строкой Unicode, использующей для символа 16-бит. Я
должен бы был написать функцию operator wchar_t*(), тем самым
модифицировав код в функции f():
printf("%s/n", (const wchar_t*)s );
Тем не менее, одним из главных случаев, которых я стараюсь избежать
при помощи объектно-ориентированного подхода, является
необходимость модификации пользователя объекта при изменении
внутреннего определения этого объекта, поэтому преобразование в char*
неприемлемо.
Также есть проблемы со стороны внутренней согласованности. Имея
указатель на buf, возвращенный функцией operator const char*(),
вы все же можете модифицировать строку при помощи указателя и
испортить поле length, хотя для этого вам придется немного
постараться:
string s;
// ...
char *p = (char *)(const char *)s;
gets( p );
В равной степени серьезная, но труднее обнаруживаемая проблема
возникает в следующем коде:
const char *g( void )
{
string s;
// ...
return (const char *)s;
}
Операция приведения вызывает функцию operator const char*(),
возвращающую buf. Тем не менее, деструктор класса string передает
этот буфер оператору delete, когда строка покидает область действия.
Следовательно, функция g() возвращает указатель на освобожденную
память. В отличие от предыдущего примера, при этой второй проблеме
нет закрученного оператора приведения в два этапа, намекающего нам,
Правила программирования на Си++
что что-то не так.
Реализация в листинге 7 исправляет это, заменив преобразование
char
*
на обработчиков сообщений типа метода самовывода (print()).
Я бы вывел строку при помощи:
string s;
s.print( cout )
или:
cout "" s;
а не используя printf(). При этом совсем нет открытого доступа к
внутреннему буферу. Функции окружения могут меньше беспокоиться о
том, как хранятся символы, до тех пор, пока строковый объект правильно
отвечает на сообщение о самовыводе. Вы можете менять свойства
представления строки как хотите, не влияя на отправителя сообщения
print()
. Например, строковый объект мог бы содержать два буфера -
один для строк Unicode и другой для строк char* - и обеспечивать
перевод одной строки в другую. Вы могли бы даже добавить для перевода
на французский язык сообщение translate_to_French() и получить
многоязыкую строку. Такая степень изоляции и является целью объектноориентированного
программирования, но вы ее не добьетесь, если не
будете непреклонно следовать этим правилам. Здесь нет места ковбоям от
программирования.
110.1. Не пользуйтесь функциями типа get/set
(чтения и присваивания значений)
Это правило в действительности то же, что и предыдущее "все данные
должны быть закрытыми". Я выделил его, потому что есть такая
распространенная ошибка среди начинающих программистов на Си++.
Нет разницы между:
struct xxx
{
int x;
};
и:
class xxx {
private
:
int x;
public:
void setx ( int ix ){ x = ix; }
int getx ( void ) { return x; }
Вопросы проект ирования и реализации 159
}
за исключением той, что второй вариант труднее читать. Просто сделать
данные закрытыми недостаточно: вам нужно изменить образ мыслей.
Подведем итог по нескольким упомянутым ранее пунктам:
+ Сообщение реализует свойство. Открытая (public) функция
реализует обработчик сообщения. Поля данных - лишние во внешнем
мире; вы добавляете их лишь для того, чтобы иметь возможность
реализовать свойство. Доступ к ним должен быть невозможен.
Заметьте, что вы будете изредка видеть обработчик сообщений,
который ничего не делает, кроме возврата содержимого поля или
помещает в поле значение, переданное в виде аргумента. Этот обработчик
тем не менее не является функцией типа get/set. Вопрос в том, как
возникает такая ситуация. Нет абсолютно ничего плохого в том, если вы
начинаете с ряда сообщений и затем решаете, что самым простым
способом реализации сообщения является помещение специального поля
в определение класса. Другими словами, этот обработчик сообщений не
является усложненным способом доступа к полю; скорее, это поле
является простым способом реализовать сообщение. Хотя вы попали в то
же место, вы попали туда совершенно другим путем.
Конечно, эта организация означает, что Си++ не может быть
эффективно использован в гибридной среде Си/Си++, потому что
интерфейс между двумя половинами программы уничтожает
инкапсуляцию, которой вы так сильно старались добиться. В известном
смысле жаль, что Си++ создан на основе Си, потому что это просто
подстрекает нас к ошибкам.
Закончу этот раздел более реальным примером. Как-то раз я видел
интерфейс, в котором объект "календарь" позволял пользователю
интерактивно выбирать дату, щелкая мышью на каком-либо из дней,
показанных на изображении календаря. "Календарь" затем
экспортирует эту дату в другие части программы, помещая ее в объект
"дата", который возвращается из сообщения get_date(). Проблема
здесь в том, что проектирование выполнено выполнено наизнанку.
Программист мыслил структурными категориями, а не объектноориентированными.
При выполнении должным образом единственным видимым в других
частях программы объектом был бы объект "дата". "Дата" использовала
бы объект "календарь" для реализации сообщения
"инициализируй_себя" (которое могло бы быть конструктором), но
"календарь" бы содержался внутри "даты". Определение класса
Правила программирования на Си++
"календарь" можно было бы даже вложить в определение класса
"дата". Объект "дата" также мог бы поддерживать другие
инициализирующие сообщения, такие как
"инициализируй_себя_от_редактируемого_ввода" или
"инициализируй_себя_из_строки", но во всех случаях объект "дата"
отвечает за нужное для инициализации взаимодействие с
пользовательским интерфейсом. Остальная часть программы просто бы
непосредственно использовала "дату"; никто, кроме "даты", даже бы не
знал о существовании объекта "календарь". То есть вы бы объявили
"дату" и приказали ей себя инициализировать. Затем вы можете
передавать объект "дата" всюду, куда необходимо. Конечно, "дата"
должна также уметь себя вывести, переслать в файл или из файла,
сравнить себя с другими датами и так далее.
111. Откажитесь от выражений языка Си, когда
программируете на Си++
Многие из проблем, рассмотренных в предыдущих правилах, вызваны
программистами на Си, не желающими отказаться от знакомых
выражений Си при переходе на Си++. Та же самая проблема существует и
в естественных языках: вам будет тяжело заставить себя понять пофранцузски,
если вы просто переведете английские выражения в их
буквальные эквиваленты.
Хорошим примером этой проблемы в Си++ является char*.
Большинство программистов на Си ни за что не соглашаются отказаться
от использования строк в виде char*. Проблема заключается в том, что
вы привыкли смотреть на char* и думать, что это строка. Это не строка.
Это указатель. Убежденность в том, что указатель - это строка, обычно
вызывает проблемы, некоторые из которых я уже рассматривал, а другие
будут рассмотрены позднее.
Симптомами этой проблемы является появление char* где-нибудь в
программе, которая поддерживает класс string; вы должны делать все
на языке string. Обобщим это: чтобы заставить объектноориентированную
систему работать, все должно быть объектами.
Основные типы Си не очень применимы, за исключением глубоких недр
низкоуровневых функций-членов класса низкого уровня. Инкапсуляция
вашего char* в классе string решит множество проблем, и потратите
массу времени, пытаясь поддерживать char*, при том, что существует
вполне хороший класс string, который может делать ту же работу.
Вопросы проект ирования и реализации 161
Определение класса не обязательно увеличивает накладные расходы,
поэтому это не может быть оправданием. Если ваш класс string имеет
единственное поле char*, и если все из методов являются встроенными
функциями, то ваши накладные расходы не превысят те, которые бы у вас
были при прямом использовании char*, но зато вы получите все выгоды
сопровождения, предоставляемые классами Си++. Более того, у вас будет
возможность наследовать от string, что невозможно с char*.
Возьмем в качестве примера управляющий элемент-редактор Windows
- маленькое окно, в котором пользователь вводит данные.
(Программисты для X-Window, для вас "управляющий элемент" Windows
- это примерный эквивалент widget). Управляющий элемент-редактор
имеет все свойства как окна, так и строки, и, следовательно, вам было бы
желательно его реализовать, наследуя одновременно от класса window и
от класса string.
112. Проектируйте с учетом наследования
Никогда не надейтесь, что класс не будет использоваться в качестве
базового класса. Сосредоточимся на случае с примером управляющего
элемента-редактора из предыдущего правила. Я бы хотел реализовать
такой элемент, наследуя одновременно от класса window и от класса
string
, потому что он обладает свойствами обоих. У меня ничего бы не
получилось, если бы многие из функций string не были виртуальными.
То есть, так как я могу делать со строкой следующее:
string str = "xxx"; // инициализировать строку значением "xxx"
str = "
Абв"; // заменить предыдущее значение на "Абв"
str += "
где"; // присоединяет "где" к имеющейся строке.
то хотел иметь возможность делать следующее, чтобы поместить текст
как в буфер, принадлежащий управляющему элементу-редактору, так и в
соответствующее окно:
class edit_control : public string
, public window
{/* ... */}
edit_control edit = "xxx";
edit = "
Абв";
edit += "
где";
Я бы также хотел передавать свой объект edit_control в функцию,
ожидающую в качестве аргумента string, так чтобы любые изменения,
которые эта функция делает в (том, что она принимает за) string,
автоматически отображались и в окне управляющего элемента-редактора.
Правила программирования на Си++
Все это не возможно, если функции, подобные operator=() и
operator
+=()
, не виртуальные в классе string и, тем самым, не
позволяющие мне менять их поведение в производном классе
edit_control
. Например, так как функция operator=() класса
string
из листинга 7 со страницы 155 является виртуальной, то я могу
сделать следующее:
class edit_control : public string
, public window
{
// ...
virtual string &operator=( const string &r );
}
virtual string &edit_control::operator=( const string &r )
{
*(string *)this = r;
window::caption() = r; // операция разрешения видимости
// window:: просто для ясности
}
Следующей функции может быть передан или простой объект string,
или объект edit_control; она не знает или ей все равно, какой
конкретно:
f( string *s )
{
// ...
*s = "
Новое значение" ;
}
В случае объекта string внутренний буфер обновляется. В случае
edit_control
буфер обновляется, но также модифицируется заголовок
его окна.
112.1. Функция-член должна обычно использовать
закрытые поля данных класса
Так как все открытые функции-члены являются обработчиками
сообщений, а все закрытые функции и поля данных просто поддерживают
открытых обработчиков сообщений, то где-то есть ошибка, если функция
не имеет доступа к полям данных или не может вызвать функцию,
имеющую к ним доступ. Эта функция должна, вероятно, быть
передвинута на глобальный уровень или в другой класс.
Ясным признаком того, что вы сделали что-то неправильно, является
функция из одного класса, требующая для своей работы доступа к полям
объекта другого класса (в отличие от того, чтобы иметь указатель на
Вопросы проект ирования и реализации 163
другой объект для передачи этому объекту сообщения). В самом худшем
случае класс "хозяин" дает статус дружественного классу "гость", и
функция-член класса "гость" использует указатель "хозяина" для доступа
к его полям, но не может получить никакого доступа к любому из полей
своего собственного класса. Механизм дружественности часто неверно
используется таким способом, но класс должен давать статус друга только
так, чтобы друг мог посылать закрытые сообщения классу, дарящему
дружбу. Дружественный класс никогда не должен иметь дос
...Поділитися у соцмережах