Главная страница
Библиотека (скачать книги)
Скачать софт
Введение в программирование
Стандарты для C++
Уроки по C#
Уроки по Python
HTML
Веб-дизайн
Ассемблер в среде Windows
ActiveX
Javascript
Общее о Линукс
Линукс - подробно
Линукс - новое
Delphi
Паскаль для начинающих
Турбопаскаль
Новости
Партнеры
Наши предложения
Архив новостей







40. Избегайте возможностей неявного преобразования типов

Резюме

Не все изменения прогрессивны: неявные преобразования зачастую приносят больше вреда, чем пользы. Дважды подумайте перед тем, как предоставить возможность неявного преобразования к типу и из типа, который вы определяете, и предпочитайте полагаться на явные преобразования (используйте конструкторы, объявленные как explicit, и именованные функции преобразования типов).

Обсуждение

Неявные преобразования типов имеют две основные проблемы.

  • Они могут проявиться в самых неожиданных местах.

  • Они не всегда хорошо согласуются с остальными частями языка программирования.

Неявно преобразующие конструкторы (конструкторы, которые могут быть вызваны с одним аргументом и не объявлены как explicit) плохо взаимодействуют с перегрузкой и приводят к созданию невидимых временных объектов. Преобразования типов, определенные как функции-члены вида operator T (где T — тип), ничуть не лучше — они плохо взаимодействуют с неявными конструкторами и позволяют без ошибок скомпилировать разнообразные бессмысленные фрагменты кода (примеров чего несть числа — см. приведенные в конце рекомендации ссылки; мы приведем здесь только пару из них).

В C++ последовательность преобразований типов может включать не более одного пользовательского преобразования. Однако когда в эту последовательность добавляются встроенные преобразования, ситуация может оказаться предельно запутанной. Решение здесь простое и состоит в следующем.

  • По умолчанию используйте explicit в конструкторах с одним аргументом (см. рекомендацию 54):

    class Widget { // ...
        explicit Widget(unsigned int widgetizationFactor);
        explicit Widget(const char* name, const Widget* other = 0);
    };
    
  • Используйте для преобразований типов именованные функции, а не соответствующие операторы:

    class String { // ...
        const char* as_char_pointer() const;  // В традициях c_str
    };
    

См. также обсуждение копирующих конструкторов, объявленных как explicit, в рекомендации 54.

Примеры

Пример 1. Перегрузка. Пусть у нас есть, например, Widget::Widget(unsigned int), который может быть вызван неявно, и функция Display, перегруженная для Widget и doublе. Рассмотрим следующий сюрприз при разрешении перегрузки:

void Display(double);           // Вывод double
void Display(const Widget&);    // Вывод Widget
Display(5);                     // Гм! Создание и вывод Widget

Пример 2. Работающие ошибки. Допустим, вы снабдили класс Stringоператором operator const char*:

class String {
// ...
public:
    operator const char*();  // Грустное решение...
};

В результате этого становятся компилируемыми масса глупостей и опечаток. Пусть s1и s2 — объекты типа String. Все приведенные ниже строки компилируются:

int х = si - s2;             // Неопределенное поведение
const char* p = si - 5;      // Неопределенное поведение
р = si + '0';                // Делает не то, что вы ожидаете
if( si == "0" ) { ... }      // Делает не то, что вы ожидаете

Именно по этой причине в стандартном классе string отсутствует operator const char*.

Исключения

При нечастом и осторожном использовании неявные преобразования типов могут сделать код более коротким и интуитивно более понятным. Стандартный класс std::string определяет неявный конструктор, который получает один аргумент типа const char*. Такое решение отлично работает, поскольку проектировщики класса приняли определенные меры предосторожности.

  • Не имеется автоматического преобразования std::string в const char*; такое преобразование типов выполняются при помощи двух именованных функций — c_str и data.

  • Все операторы сравнений, определенные для std::string (например, ==, !=, <), перегружены для сравнения const char* и std::string в любом порядке (см. рекомендацию 29). Это позволяет избежать создания скрытых временных переменных.

Но и при этом возникают определенные неприятности, связанные с перегрузкой функций.

void Display( int );
void Display( std::string );

Display( NULL );   // вызов Display(int)

Этот результат для некоторых может оказаться сюрпризом. (Кстати, если бы выполнялся вызов Display(std::string), код бы обладал неопределенным поведением, поскольку создание std::string из нулевого указателя некорректно, но конструктор этого класса не обязан проверять передаваемое ему значение на равенство нулю.)

41. Делайте данные-члены закрытыми (кроме случая агрегатов в стиле структур C)

Резюме

Данные-члены должны быть закрыты. Только в случае простейших типов в стиле структур языка C, объединяющих в единое целое набор значений, не претендующих на инкапсуляцию и не обеспечивающих поведение, делайте все данные-члены открытыми. Избегайте смешивания открытых и закрытых данных, что практически всегда говорит о бестолковом дизайне.

Обсуждение

Сокрытие информации является ключом к качественной разработке программного обеспечения (см. рекомендацию 11). Желательно делать все данные-члены закрытыми; закрытые данные — лучшее средство для сохранения инварианта класса, в том числе при возможных вносимых изменениях.

Открытые данные — плохая идея, если класс моделирует некоторую абстракцию и, следовательно, должен поддерживать инварианты. Наличие открытых данных означает, что часть состояния вашего класса может изменяться неконтролируемо, непредсказуемо и асинхронно с остальной частью состояния. Это означает, что абстракция разделяет ответственность за поддержание одного или нескольких инвариантов с неограниченным множеством кода, который использует эту абстракцию, и совершенно очевидно, что такое положение дел просто недопустимо с точки зрения корректного проектирования.

Защищенные данные обладают всеми недостатками открытых данных, поскольку наличие защищенных данных означает, что абстракция разделяет ответственность за поддержание одного или нескольких инвариантов с неограниченным множеством кода — теперь это код существующих и будущих производных классов. Более того, любой код может читать и модифицировать защищенные данные так же легко, как и открытые — просто создав производный класс и используя его для доступа к данным.

Смешивание открытых и закрытых данных-членов в одном и том же классе является непоследовательным и попросту запутывает пользователей. Закрытые данные демонстрируют, что у вас есть некоторые инварианты и нечто, предназначенное для их поддержания. Смешивание их с открытыми данными-членами означает, что при проектировании так окончательно и не решено, должен ли класс представлять некоторую абстракцию или нет.

Не закрытые данные-члены почти всегда хуже даже простейших функций для получения и установки значений, поскольку последние обеспечивают устойчивость кода к возможным внесениям изменений.

Подумайте о сокрытии закрытых членов класса с использованием идиомы Pimpl (см. рекомендацию 43).

Примеры

Пример 1. Корректная инкапсуляция. Большинство классов (например, Matrix, File, Date, BankAccount, Security) должны закрывать все данные-члены и открывать соответствующие интерфейсы. Позволение вызывающему коду непосредственно работать с внутренними данными класса работает против представленной им абстракции и поддерживаемых им инвариантов.

Агрегат Node, широко используемый в реализации класса List, обычно содержит некоторые данные и два указателя на Node: next_ и prev_. Данные-члены Node не должны быть скрыты от List. Однако не забудьте рассмотреть еще пример 3.

Пример 2. TreeNode. Рассмотрим контейнер Tree<Т>, реализованный с использованием TreeNode<T>, агрегата, используемого в Tree, который хранит указатели на предыдущий, следующий и родительский узлы и сам объект T. Все члены TreeNode могут быть открытыми, поскольку их не надо скрывать от класса Tree, который непосредственно манипулирует ими. Однако класс Tree должен полностью скрывать класс TreeNode (например, как вложенный закрытый класс или как определенный только в файле реализации класса Tree), поскольку это — детали внутренне реализации класса Tree, от которых не должен зависеть и с которыми не должен иметь дела вызывающий код. И наконец, Tree не скрывает содержащиеся в контейнере объекты T, поскольку за них отвечает вызывающий код; контейнеры используют абстракцию итераторов для предоставления доступа к содержащимся объектам, в то время как внутренняя структура контейнера остается скрытой.

Пример 3. Функции получения и установки значений. Если не имеется лучшей предметной абстракции, открытые и защищенные данные-члены (например, color) могут, как минимум, быть сделаны закрытыми и скрыты за функциями получения и установки значений (например, GetColor, SetColor). Тем самым обеспечивается минимальный уровень абстракции и устойчивость к изменениям.

Использование функций повышает уровень общения по поводу "цвета" от конкретного состояния до абстрактного, которое мы можем реализовать тем способом, который сочтем наиболее приемлемым. Мы можем изменить внутреннее представление цвета, добавить код для обновления дисплея при изменении цвета, добавить какие-то инструментальные средства или внести еще какие-то изменения — и все это без каких-либо изменений в вызывающем коде. В худшем случае вызывающий код потребуется перекомпилировать (т.е. мы сохраняем совместимость на уровне исходных текстов); в лучшем — не потребуется ни перекомпиляция, ни даже перекомпоновка (если изменения сохраняют бинарную совместимость). Ни совместимость на уровне исходных текстов, ни бинарная совместимость при внесении таких изменений невозможны, если исходный дизайн содержит открытый член color, с которым тесно связан вызывающий код.

Исключения

Функции получения и установки значений полезны, но дизайн класса, состоящего практически из одних таких функций, оставляет желать лучшего. Подумайте над тем, требуется ли в таком случае обеспечение абстракции или достаточно ограничиться простой структурой.

Агрегаты значений (известные как структуры в стиле C) просто хранят вместе набор различных данных, но при этом не обеспечивают ни их поведение, ни делают попыток моделирования абстракций или поддержания инвариантов. Такие агрегаты не предназначены для того, чтобы быть абстракциями. Все их данные-члены должны быть открытыми, поскольку эти данные-члены и представляют собой интерфейс. Например, шаблон класса std::pair<T, U> используется стандартными контейнерами для объединения двух несвязанных элементов типов T и U, и при этом pair сам по себе не привносит ни поведения, ни каких-либо инвариантов.

42. Не допускайте вмешательства во внутренние дела

Резюме

Избегайте возврата дескрипторов внутренних данных, управляемых вашим классом, чтобы клиенты не могли неконтролируемо изменять состояние вашего объекта, как своего собственного.

Обсуждение

Рассмотрим следующий код:

class Socket {
public:
    // ... конструктор, который открывает handle_,
    // деструктор, который закрывает handle_, и т.д. ...
    int GetHandle() const { return handle_; }  // Плохо!
private:
    int handle_;  // дескриптор операционной системы
};

Сокрытие данных — мощный инструмент абстракции и модульности (см. рекомендации 11 и 41). Однако сокрытие данных при одновременном обеспечении доступа к их дескрипторам обречено на провал, потому что это то же, что и закрыть свою квартиру на замок и положить ключ под коврик у входа или просто оставить его в замке. Вот почему это так.

  • В этом случае клиент имеет две возможности реализации функциональности. Он может воспользоваться абстракцией вашего класса (Socket) либо непосредственно работать с реализацией, на которой основан ваш класс (дескриптор сокета в стиле С). В последнем случае объект оказывается не осведомлен об изменениях, происходящих с ресурсом, которым он, как ему кажется, владеет. Теперь класс не в состоянии надежно обогатить или усовершенствовать функциональность (например, обеспечить про-кси, журнализацию, сбор статистики и т.п.), поскольку клиенты могут просто обойти эти возможности реализации, как и любые другие инварианты, которые вы, как вы полагаете, добавили в ваш класс. Это делает невозможной, в частности, корректную обработку возникающих ошибок (см. рекомендацию 70).

  • Класс не может изменять внутреннюю реализацию своей абстракции, поскольку от нее зависят клиенты. Если в будущем класс Socket будет обновлен для поддержки другого протокола с использованием других низкоуровневых примитивов, вызывающий код, который будет по-прежнему получать доступ к дескриптору handle_ и работать с ним, окажется некорректным.

  • Класс не в состоянии обеспечить выполнение его инвариантов, поскольку вызывающий код может изменить состояние без ведома класса. Например, кто-то может закрыть дескриптор, используемый объектом Socket, минуя вызов функции-члена Socket, а это приведет к тому, что объект станет недействительным.

  • Код клиента может хранить дескрипторы, возвращаемые вашим классом, и пытаться использовать их после того, как код вашего класса сделает их недействительными.

Распространенная ошибка заключается в том, что действие const на самом деле неглубокое и не распространяется посредством указателей (см. рекомендацию 15). Например, Socket::GetHandle— константный член; пока мы рассматриваем ситуацию с точки зрения компилятора, возврат handle_ сохраняет константность объекта. Однако непосредственный вызов функций операционной системы с использованием значения handle_ вполне может изменять данные, к которым косвенно обращается handle_.

Приведенный далее пример очень прост, хотя в данном случае ситуация несколько лучше — мы можем снизить вероятность случайного неверного употребления возвращаемого значения, описав его тип как const:

class String {
    char* buffer_;
public:
    char* GetBuffer() const { return buffer_; }
    // плохо: следует возвращать const char*
    // ...
};

Хотя функция GetBuffer константная, технически этот код вполне корректен. Понятно, что клиент может использовать эту функцию GetBuffer для того, чтобы изменить объект String множеством разных способов, не прибегая к явному преобразованию типов. Например, strcpy(s.GetBuffer(), "Very Long String...") — вполне законный код; любой компилятор пропустит его без каких бы то ни было замечаний. Если бы мы объявили возвращаемый тип как const char*, то представленный код вызвал бы, по крайней мере, ошибку времени компиляции, так что случайно поступить столь опасно было бы просто невозможно — вызывающий код должен был бы использовать явное преобразование типов (см. рекомендации 92 и 95).

Но даже возврат указателей на const не устраняет возможности случайного некорректного использования, поскольку имеется еще одна проблема, связанная с корректностью внутренних данных класса. В приведенном выше примере с классом String, вызывающий код может сохранить значение, возвращаемое функцией GetBuffer, а затем выполнить операции, которые приведут к росту (и перемещению) буфера String, что в результате может привести к использованию сохраненного, но более недействительного указателя на несуществующий в данный момент буфер. Таким образом, если вы считаете, что у вас есть причины для обеспечения такого доступа ко внутреннему состоянию, вы должны детально документировать, как долго возвращаемое значение остается корректным и какие операции делают его недействительным (сравните с гарантиями корректности явных итераторов стандартной библиотеки.

Исключения

Иногда классы обязаны предоставить доступ ко внутренним дескрипторам по причинам, связанным с совместимостью, например, для интерфейса со старым кодом или при использовании других систем. Например, std::basic_string предоставляет доступ к своему внутреннему дескриптору посредством функций-членов data и c_str для совместимости с функциями, которые работают с указателями C — но не для того, чтобы хранить эти указатели и пытаться выполнять запись с их помощью! Такие функции доступа "через заднюю дверь" всегда являются злом и должны использоваться очень редко и очень осторожно, а условия корректности возвращаемых ими дескрипторов должны быть точно документированы.

43. Разумно пользуйтесь идиомой Pimpl

Резюме

C++ делает закрытые члены недоступными, но не невидимыми. Там, где это оправдывается получаемыми преимуществами, следует подумать об истинной невидимости, достигаемой применением идиомы Pimpl (указателя на реализацию) для реализации брандмауэров компилятора и повышения сокрытия информации (см. рекомендации 11 и 41).

Обсуждение

Когда имеет смысл создать "брандмауэр компилятора", который полностью изолирует вызывающий код от закрытых частей класса, воспользуйтесь идиомой Pimpl (указателя на реализацию): скройте их за непрозрачным указателем (указатель (предпочтительно подходящий интеллектуальный) на объявленный, но пока не определенный класс). Например:

class Map {
    // ...
private:
    struct Impl;
    shared_ptr<Impl> pimpl_;
};

Дающий название идиоме указатель должен использоваться для хранения всех закрытых членов, как данных, так и закрытых функций-членов. Это позволяет вам вносить произвольные изменения в закрытые детали реализации ваших классов без какой бы то ни было реком-пиляции вызывающего кода. Свобода и независимость — вот отличительные черты рассматриваемой идиомы (см. рекомендацию 41).

Примечание: объявляйте указатель на закрытую реализацию, как показано — с использованием двух объявлений. Если вы скомбинируете две строки с предварительным объявлением типа и указателя на него в одну инструкцию struct Impl *pimpl;, это будет вполне законно, но изменит смысл объявления: в этом случае Impl находится в охватывающем пространстве имен и не является вложенным типом вашего класса.

Имеется как минимум три причины для использования Pimpl, и все они вытекают из различия между доступностью (в состоянии ли вы вызвать или использовать некоторый объект) и видимостью (видим ли этот объект для вас и, таким образом, зависите ли вы от его определения) в C++. В частности, все закрытые члены класса недоступны никому, кроме функций-членов и друзей, но зато видимы всем — любому коду, которому видимо определение класса.

Первое следствие этого — потенциально большее время сборки приложения из-за обработки излишних определений типов. Для закрытых данных-членов, хранящихся по значению, и параметров закрытых функций-членов, передаваемых по значению или используемых в видимой реализации функций, типы должны быть определены, даже если они никогда не потребуются в данной единице компиляции. Это может привести к увеличению времени сборки, например:

class C {
    // ...
private:
    AComplicatedType act_;
};

Заголовочный файл, содержащий определение класса C, должен также включать заголовочный файл, содержащий определение AComplicatedType, который в свою очередь транзитивно включает все заголовочные файлы, которые могут потребоваться для определения AComplicatedType, и т.д. Если заголовочные файлы имеют большие размеры, время компиляции может существенно увеличиться.

Второе следствие — создание неоднозначностей и сокрытие имен для кода, который пытается вызвать функцию. Несмотря на то, что закрытая функция-член не может быть вызвана кодом вне ее класса и его друзей, она тем не менее участвует в поиске имен и разрешении перегрузки и тем самым может сделать вызов неоднозначным или некорректным. Перед выполнением проверки доступности C++ выполняет поиск имен и разрешение перегрузки. Из-за этого видимость имеет более высокий приоритет:

int Twice( int );            // 1

class Calc {
public:
    string Twice( string );  // 2
private:
    char* Twice( char* );    // 3
    int Test() {
        return Twice( 21 );  // А: ошибка, функции 2 и 3 не
               // подходят (могла бы подойти функция 1, но
               // ее нельзя рассматривать, так она скрыта от
    }          // данного кода)
};

Calc с;
c.Twice( "Hello" );          //Б: ошибка, функция 3
               // недоступна (могла бы использоваться
               // функция 2, но она не рассматривается, так
               // как у функции 3 лучшее соответствие
               // аргументу)

В строке А обходной путь состоит в том, чтобы явно квалифицировать вызов как ::Twice(21) для того, чтобы заставить поиск имен выбрать глобальную функцию. В строке Б обходной путь состоит в добавлении явного преобразования типа c.Twiсе(string("Hello")) для того, чтобы заставить разрешение перегрузки выбрать соответствующую функцию. Некоторые из таких проблем, связанных с вызовами, можно решить и без применения идиомы Pimpl, например, никогда не используя закрытые перегрузки функций-членов, но не для всех проблем, разрешимых при помощи идиомы Pimpl, можно найти такие обходные пути.

Третье следствие влияет на обработку ошибок и безопасность. Рассмотрим пример Widget Тома Каргилла (Tom Cargill):

class Widget { // ...
public:
    Widget& operator=( const Widget& );
private:
    Tl tl_;
    T2 t2_;
};

Коротко говоря, мы не можем написать оператор operator=, который обеспечивает строгую гарантию (или хотя бы базовую гарантию), если операции T1 или Т2 могут давать необратимые сбои (см. рекомендацию 71). Хорошие новости, однако, состоят в том, что приведенная далее простая трансформация всегда обеспечивает, как минимум, базовую гарантию для безопасного присваивания, и как правило — строгую гарантию, если необходимые операции T1 и Т2 (а именно — конструкторы и деструкторы) не имеют побочных эффектов. Для этого следует хранить объекты не по значению, а посредством указателей, предпочтительно спрятанными за единственным указателем на реализацию:

class Widget { // ...
public:
    Widget& operator=( const Widget& );
private:
    struct Impl;
    shared_ptr<Impl> pimpl_;
};

Widget& Widget::operator=( const Widget& ) {
    shared_ptr<Impl> temp( new Impl( /*...*/ ) );
    // изменяем temp->tl_ и temp->t2_; если какая-то из
    // операций дает сбой, генерируем исключение, в
    // противном случае - принимаем внесенные изменения:
    pimpl_ = temp;
    return *this;
}

Исключения

В то время как вы получаете все преимущества дополнительного уровня косвенности, проблема состоит только в увеличении сложности кода (см. рекомендации 6 и 8).



 
 

Библиотека программиста. 2009.
Администратор: admin@programmer-lib.ru