Голуб Ален И. - Веревка достаточной длины, чтобы... выстрелить с. Руководство по программированию. Автору удается сделать изложение столь серьезной темы живым и интересным за счет рассыпанного по тексту юмора и глубокого знания предмета
Скачать 1.36 Mb.
|
149 Затем, у child есть mother и father , у каждого из которых есть parent . Проблема с philip.go_to_sleep() состоит в том, что компилятор не знает, какой из объектов parent должен получить это сообщение: тот, который в mother , или тот, который в father 8 Одним из путей решения этой проблемы является введение уточняющей функции, которая направляет сообщение нужному классу (или обоим): class parent { public : go_to_sleep(); }; class mother : public parent {}; class father : public parent {}; class child : public mother, public father { public : go_to_sleep() { mother::go_to_sleep(); father::go_to_sleep(); } } Другим решением является виртуальный базовый класс: class parent {}; class mother : virtual public parent {}; class father : virtual public parent {}; class child : public mother, public father {} который заставляет компилятор помещать в объект child лишь один объект parent , совместно используемый объектами mother и father Двусмысленность исчезает, но появляются другие проблемы. Во-первых, нет возможности показать на уровне потомка, хотите вы или нет виртуальный базовый класс. Например, в следующем коде tree_list_node может быть членом как дерева, так и списка одновременно: class node; 8 На самом деле правильнее сказать, что во время компиляции компилятор не знает, от какого из базовых классов parent объект child наследует обработчик сообщения go_to_sleep(), хотя эта правильность и может сбить с толку. Вы можете спросить, почему неопределенность имеет значение, ведь эта функция одна и та же в обоих классах. Компилятор не может создать ветвление времени выполнения, так как не знает, какое значение присвоить указателю this , когда он вызывает функцию-член базового класса. Правила программирования на Си++ 150 class list_node : public node {}; class tree_node : public node {}; class tree_list_node : public list_node, public tree_node {}; В следующем варианте tree_list_node может быть членом или дерева, или списка, но не обоих одновременно: class node; class list_node : virtual public node {}; class tree_node : virtual public node {}; class tree_list_node : public list_node, public tree_node {}; Вам бы хотелось делать этот выбор при создании tree_list_node , но такой возможности нет. Второй проблемой является инициализация. Конструкторы в list_node и tree_node , вероятно, инициализируют базовый класс node , но разными значениями. Если имеется всего один 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 на диске */ } }; class superdoc : public doc1, public doc2 {}; persistent *p = new superdoc(); p->flush(); // ОШИБКА: какая из функций flush() вызвана? Вопросы проектирования и реализации 151 102. Смешения не должны наследоваться от чего попало 103. Смешения должны быть виртуальными базовыми классами 104. Инициализируйте виртуальные базовые классы при помощи конструктора, используемого по умолчанию Вы можете свести до минимума рассмотренные ранее проблемы, стараясь придерживаться следующих правил (многие смешения не могут соответствовать им всем, но вы делайте все от вас зависящее): • Если можно, то смешения не должны наследоваться от чего попало, тем самым полностью устраняя проблему ромбовидной иерархии при множественном наследовании. • Для смешения должна обеспечиваться возможность быть виртуальным базовым классом для того, чтобы не возникала проблема неопределенности в случае, если у вас все же получилась ромбовидная структура классов. • Если можно, то смешение должно всегда строиться с использованием только конструктора по умолчанию (не имеющего аргументов). Это упрощает оформление смешения в качестве виртуального базового класса, потому что вам не нужно будет заботиться об инициализации большей части наследуемого объекта. В конце концов, по умолчанию всегда используется конструктор по умолчанию. Правила программирования на Си++ 152 105. Наследование не подходит, если вы никогда не посылаете сообщения базового класса объекту производного класса 106. Везде, где можно, предпочитайте включение наследованию 107. Используйте закрытые базовые классы лишь когда вы должны обеспечить виртуальные замещения Главная выгода от наследования состоит в том, что вы можете писать универсальный код, манипулирующий объектами обобщенного базового класса, и тот же самый код может также манипулировать объектами производного класса (или точнее, может манипулировать компонентом базового класса в объекте производного класса). Например, вы можете написать функцию, которая печатает список объектов фигура, но этот список на самом деле содержит объекты, которые унаследованы от фигуры, такие как круг и линия. Тем не менее, функции печати этого знать не нужно. Она вполне довольна, считая их обобщенными фигурами. Это качество является тем, что имеют в виду, когда говорят о повторном использовании кода. Вы повторно используете один и тот же код для разных дел: временами он печатает круг, временами — линию. Если вы обнаружили у себя объект производного класса, от которого никогда не требуется использовать возможности базового класса, то, вероятно, в проектировании иерархии есть какая-то ошибка, хотя встречаются редкие случаи, когда такое поведение приемлемо; поэтому в языке есть закрытые базовые классы. Но все же включение (назначение объекта полем в классе, а не базовым классом) всегда лучше, чем наследование (при условии, конечно, что у вас есть выбор). Если объект производного класса никогда не получает сообщения базового класса, то вероятнее всего компонент базового класса в объекте производного класса действительно должен быть полем, и наследование вовсе не должно использоваться. Вместо вот этого: class derived : public base { }; вам почти всегда лучше делать так: class derived { base base_obj; Вопросы проектирования и реализации 153 }; Используйте закрытые базовые классы лишь в случаях, когда вам нужно в производном классе перегружать виртуальные функции базового класса. Удачный пример подобного неправильного использования наследования есть во многих иерархиях классов для Windows, которые наследуют классы типа "диалоговое окно" от "окна". Однако в реальной программе вы никогда не посылаете относящиеся к окну сообщения (типа "сдвинуться" или "изменить размер") в диалоговое окно. То есть диалоговое окно не является окном, по крайней мере, с точки зрения того, как диалоговое окно используется в программе. Скорее диалоговое окно использует окно, чтобы себя показать. Слово "является" подразумевает наследование, а "использует" — включение, которое здесь лучше подходит. Подобное плохое проектирование, между прочим, обычно имеет причиной отступление от правила определения объектов в первую очередь. То есть концепция "окна" в Microsoft Windows имеет смысл только для подсистемы визуального вывода. Диалоговое окно изображается в виде окна, но это не значит, что это окно, даже если подсистема визуального вывода предпочитает его рассматривать в этом качестве. Плохое проектирование получается, когда исходят из существующей системы визуального вывода и затем помещают вокруг нее оболочку при помощи библиотеки классов, вместо того, чтобы исходить из описания программы, решая затем, как реализовать в программе реальные объекты. 108. Проектируйте структуры данных в последнюю очередь Добавление полей данных выполняется в процессе проектирования в последнюю очередь. Другими словами, после того, как вы разработали сообщения, вам нужно понять, как реализовать возможности, запрашиваемые этими сообщениями. Вероятно, это труднейшая часть процесса объектно-ориентированного проектирования для структурного программиста: заставить себя не думать о лежащей в основе структуре данных до тех пор, пока не будет готовы полностью система обмена сообщениями и иерархия классов. В этот момент процесса проектирования вы также добавляете закрытые ( private ) "рабочие" (или "вспомогательные") функции, которые помогают обработчикам сообщений справиться со своей работой. Правила программирования на Си++ 154 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; // длина буфера (не строки); 5 6 public : 7 virtual 8 string( void ); 9 string( const char *input_str = "" ); 10 string( const string &r ); 11 12 virtual const string & operator =( const string &r ); 13 14 virtual int operator < ( const string &r ) const ; 15 virtual int operator > ( const string &r ) const ; 16 virtual int operator ==( const string &r ) const ; 17 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 { Правила программирования на Си++ 156 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 } 55 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 // реализован метод вывода строкой своего значения. 82 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 исправляет это, заменив преобразование Правила программирования на Си++ 158 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() . Проблема здесь в том, что проектирование выполнено выполнено наизнанку. Программист мыслил структурными категориями, а не объектно- ориентированными. При выполнении должным образом единственным видимым в других частях программы объектом был бы объект " дата ". " Дата " использовала бы объект " календарь " для реализации сообщения " инициализируй_себя " (которое могло бы быть конструктором), но " календарь " бы содержался внутри " даты ". Определение класса " календарь " можно было бы даже вложить в определение класса " дата ". Объект "дата" также мог бы поддерживать другие инициализирующие сообщения, такие как Правила программирования на Си++ 160 " инициализируй_себя_от_редактируемого_ввода " или " инициализируй_себя_из_строки ", но во всех случаях объект " дата " отвечает за нужное для инициализации взаимодействие с пользовательским интерфейсом. Остальная часть программы просто бы непосредственно использовала " дату "; никто, кроме " даты ", даже бы не знал о существовании объекта " календарь ". То есть вы бы объявили " дату " и приказали ей себя инициализировать. Затем вы можете передавать объект " дата " всюду, куда необходимо. Конечно, " дата " должна также уметь себя вывести, переслать в файл или из файла, сравнить себя с другими датами и так далее. 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 , автоматически отображались и в окне управляющего элемента-редактора. Правила программирования на Си++ 162 Все это не возможно, если функции, подобные 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 другой объект для передачи этому объекту сообщения). В самом худшем случае класс "хозяин" дает статус дружественного классу "гость", и функция-член класса "гость" использует указатель "хозяина" для доступа к его полям, но не может получить никакого доступа к любому из полей своего собственного класса. Механизм дружественности часто неверно используется таким способом, но класс должен давать статус друга только так, чтобы друг мог посылать закрытые сообщения классу, дарящему дружбу. Дружественный класс никогда не должен иметь доступ к данным другого класса; это сцепление слишком сильное. Вы часто видите эту ошибку в архитектурах "документ/отображение" типа MacApp и MFC. С точки зрения архитектуры, "документ" содержит данные, а "отображение" реализует пользовательский интерфейс. Трудности возникают, когда вы хотите показать какие-нибудь данные в своем "отображении". Никогда не позволяйте "отображению" доступ к полям "документа" для их показа. Данные любого класса, включая "документ", должны быть тщательно охраняемым секретом. Лучшим подходом является передача "отображением" в "документ" сообщения "отобразить себя в этом окне". 9 113. Используйте константы В программы на Си класс памяти const часто не включается. На самом деле это просто небрежность, но она мало влияет на возможности программы на Си. Так как Си++ гораздо разборчивее в отношении типов, чем Си, то в Си++ это гораздо более крупная проблема. Вы должны использовать модификатор const везде, где можно; это делает код более надежным, и часто компилятор не принимает код, который его не использует. Особенно важно: • Всегда передавать указатели на константные объекты, если вы не модифицируете эти объекты. Объявление: puts( const char *p ) сообщает компилятору, что функция puts() не намерена модифицировать символы в массиве, переданном при помощи p. Это является чрезвычайно полезной порцией информации для сопровождения. 9 Пользователи MFC могут обратиться за более глубоким обсуждением этого вопроса к моей статье "Rewriting the MFC Scribble Program Using an Object-Oriented Design Approach" в августовском номере журнала "Microsoft Systems Journal" за 1995 г. Правила программирования на Си++ 164 • Все сообщения, не меняющие внутреннее состояние объекта, объявлять с модификатором const подобным образом: class cls { public : int operator ==( const cls &p ) const ; }; (Это тот модификатор const справа, относительно которого я тут распинаюсь). Этот const говорит компилятору, что передача сообщения объекту, объявленному константным, безопасна. Заметьте, что этот самый правый модификатор const в действительности создает следующее определение для указателя this : const current_class * this ; Если код в этой константной функции попытается модифицировать любое поле данных класса или предпримет вызов другой функции- члена, не помеченной const , то вы получите сообщение об ошибке компиляции такого примерно содержания "не могу преобразовать указатель на const current_class в указатель на current _class". Упомянутым указателем в данном случае является this , и никогда не будет дозволено преобразование указателя на константу в указатель на переменную (потому что вы тогда могли бы модифицировать константу при помощи указателя). Константные ссылки тоже важны и рассматриваются позже. 114. Используйте структуры только тогда, когда все данные открытые и нет функций-членов Это правило является вариантом принципа "если это похоже на Си, то должно и действовать как Си". Используйте структуры, только если вы делаете что-то в стиле Си. Следует также избегать наследования от структуры. Даже если мне многое не удалось изложить четко, надеюсь, что я прояснил смысл тезиса "закрытые данные или никакие". Зная о проблемах с прямым доступом к открытым данным, вы можете понять, почему следующее не является очень хорошей идеей: typedef struct tagSIZE // Существующее определение из // заголовочного файла Си { LONG cx; LONG cy; } SIZE; Вопросы проектирования и реализации 165 class CSize : public SIZE // Определение в файле Си++ { // ... } Я видел определения классов, подобные следующему, где требуется доступ к полям cx и cy базового класса через указатель производного класса для того, чтобы определить соответствующее им значение третьей координаты — высоты. Например: CSize some_size; some_size.cy; // тьфу! Вы должны иметь возможность написать: some_size.height(); У предшествующего кода есть другая, более трудно уловимая проблема. Наследование от существующей структуры Си часто выполняется программистом, который верит, что сможет передать объект Си++ в существующую функцию Си. То есть программист полагает, что раз наследование фактически добавляет поля к базовому классу, то производный класс в буквальном смысле будет расположен точно так же, как и базовый класс, но с присоединением нескольких дополнительных полей. Однако, это может быть и не так. Если производный класс добавляет, например, виртуальную функцию, то в базовый класс может быть добавлен указатель на таблицу виртуальных функций. Аналогично, если производный класс использует множественное наследование одновременно от структуры Си и чего-то еще, то нет никакой гарантии, что структура Си будет в начале. 115. Не размещайте тела функций в определениях классов Здесь есть несколько проблем. Если вы действительно поместите тело функции в определение класса таким образом: class amanda { public : void peekaboo( void ){ cout << "ку-ку\n"; } // функция игры // в прятки с // Амандой } Си++ делает этот класс встроенным. Первая проблема заключается в том, что такие функции с течением времени имеют тенденцию разрастаться и становятся слишком большими, чтобы быть встроенными. Поэтому Правила программирования на Си++ 166 лучше помещать определения своих встроенных функций вне определения класса, но в том же заголовочном файле, где размещается определение класса: class amanda { public: void peekaboo( void ); } class amanda::peekaboo( void ) { cout << "ку-ку\n"; } Путаница — более крупная проблема, чем размер. Часто определение класса является единственной имеющейся у вас определенной документацией по членам класса. Вам на самом деле нужно, чтобы все поместилось на одной странице, и чтобы это определение давало краткий список прототипов функций. Если имена функции и аргумента выбраны точно, то это часто вся документация, которая вам необходима. Как только вы начинаете добавлять тела функций, даже если они состоят из одной строки, к определению класса - вы эту ясность теряете. Определение класса начинает распространяться на несколько страниц, и становится трудно найти что-нибудь, используя определение класса в качестве средства документирования. Третья проблема более коварна и потребует нескольких часов на устранение, если вы не будете аккуратны. Рассмотрим фрагмент реализации связанного списка на листинге 8 (который не будет компилироваться). Классы linked_list и list_node посылают сообщения друг другу. Компилятор должен увидеть определение класса до того, как он позволит вам послать сообщение объекту этого класса. (Вы можете объявить указатель на объект, лишь глядя на class xxx ; но вы не можете ничего сделать при помощи этого указателя до завершения определения всего класса). Так как в листинге 8 используются встроенные функции, то невозможно устроить эти определения классов так, чтобы избежать предварительных ссылок. Вы можете решить эту проблему, поместив определения функций в конце того файла, где они объявлены. Я сделал это в листинге 9. Вопросы проектирования и реализации 167 Листинг 8. Фрагмент реализации связанного списка 1 class list_node; 2 3 class linked_list 4 { 5 int number_of_elements_in_list; 6 list_node *root; 7 8 private : // этот раздел содержит сообщения, получаемые 9 friend class list_node; // только от объектов list_node 10 void have_removed_an_element( void ) 11 { 12 --number_of_elements_in_list; 13 } 14 15 public : 16 void remove_this_node( list_node *p ) 17 { 18 // Следующая строка генерирует ошибку при компиляции, 19 // так как компилятор не знает, что list_node 20 // имеет сообщение remove_yourself_from_me( &root ). 21 22 p->remove_yourself_from_me( &root ); 23 } 24 25 // ... 26 }; 27 28 class list_node 29 { 30 linked_list *owner; 31 private : // Этот раздел содержит 32 friend class linked_list; // сообщения,получаемые только 33 // от объектов linked_list 34 void remove_yourself_from_me( list_node *root ) 35 { 36 // ... Выполнить удаление 37 owner->have_removed_an_element(); 38 } 39 }; Листинг 9. Улучшенный вариант реализации связанного списка 1 class list_node; 2 3 class linked_list 4 { 5 int number_of_elements_in_list; 6 list_node *root; 7 8 private : Правила программирования на Си++ 168 9 friend class list_node; 10 void have_removed_an_element( void ); 11 12 public : 13 void remove_this_node( list_node *p ); 14 15 //... 16 }; 17 //======================================================== 18 class list_node 19 { 20 linked_list *owner; 21 private : // Этот раздел содержит сообщения, 22 friend class linked_list; // получаемые только от 23 // объектов linked_list 24 25 void remove_yourself_from_me( list_node *root ); 26 }; 27 28 //======================================================== 29 // функции класса linked_list: 30 //======================================================== 31 inline void linked_list::remove_this_node( list_node *p ) 32 { 33 p->remove_yourself_from_me( &root ); 34 } 35 //-------------------------------------------------------- 36 inline void linked_list::have_removed_an_element( void ) 37 { 38 --number_of_elements_in_list; 39 } 40 41 //======================================================== 42 // функции класса list_node: 43 //======================================================== 44 void list_node::remove_yourself_from_me( list_node *root ) 45 { 46 // ... Выполнить удаление 47 owner->have_removed_an_element(); 48 } Вопросы проектирования и реализации 169 116. Избегайте перегрузки функций и аргументов, используемых по умолчанию Это правило не применяется к конструкторам и функциям перегрузки операций. Перегрузка функций, подобно многим другим свойствам Си++, была добавлена к этому языку по особым причинам. Не позволяйте себя увлечь этим. Функции, которые делают разные вещи, должны иметь и разные имена. Перегруженные функции обычно вызывают больше проблем, чем их решают. Во-первых, проблема двусмысленности: f( int , long ); f( long , int ); f( 10, 10 ); // ОШИБКА: Какую из функций я вызываю? Более коварно следующее: f( int ); f( void * ); f( 0 ); // ОШИБКА: Вызов двусмысленный Проблемой здесь является Си++, который считает, что 0 может быть как указателем, так и типом int . Если вы делаете так: const void *NULL = 0; const int ZERO = 0; то вы можете записать f(NULL) для выбора варианта с указателем и f(ZERO) для доступа к целочисленному варианту, но это ведет к большой путанице. В такой ситуации вам бы лучше просто использовать функции с двумя разными именами. Аргументы по умолчанию, создающие на самом деле перегруженные функции (по одной на каждую возможную комбинацию аргументов), также вызывают проблемы. Например, если вы написали: f( int x = 0 ); и затем случайно вызвали f() без аргументов, компилятор успешно и без возражений вставит 0 . Все, чего вы добились, — это устранили то, что в ином случае вызвало бы полезное сообщение об ошибке во время компиляции, и сдвинули ошибку на этап выполнения. Исключениями из сказанного выше являются перегруженные операции и конструкторы; многие классы имеют их по нескольку, и аргументы по умолчанию часто имеют смысл в конструкторах. Код, подобный Правила программирования на Си++ 170 следующему, вполне приемлем: class string { public : string( char *s = "" ); string( const string &r ); string( const CString &r ); // преобразование из класса MFC. // ... }; Для пояснения: разные классы будут часто обрабатывать одно и то же сообщение, реализуя функции-обработчики с совпадающими именами. Например, большинство классов реализуют сообщение print() . Смысл того, что я пытаюсь здесь добиться, такой: плохая мысль - в одном классе иметь много обработчиков сообщений с одним и тем же именем. Вместо: class string { // ... public : print( FILE *fp ); print( iostream &ios ); print( window &win ); я бы рекомендовал: class string { // ... public : print_file ( FILE *fp ); print_stream ( iostream &ios ); print_window ( window &win ); Еще лучше, если бы у вас был класс устройства device , который бы мог представлять типы: файловый FILE , потоковый iostream и оконный window , в зависимости от того, как он инициализируется — тогда бы вы могли реализовать единственную функцию print() , принимающую в качестве аргумента device Я должен сказать, что сам порой нарушаю это правило, но делаю это, зная, что, переступив черту, могу навлечь на себя беду. |