Pull to refresh

Проблемные аспекты программирования на С++

Reading time28 min
Views12K

В С++ достаточно много особенностей, которые можно считать потенциально опасными — при просчетах в проектировании или неаккуратном кодировании они легко могут привести к ошибкам. В статье приводится подборка таких особенностей, даны советы, как уменьшить их негативное влияние.




Оглавление


Оглавление

    Введение
    1. Типы
        1.1. Условные инструкции и операторы
        1.2. Неявные преобразования типа (implicit conversions)
    2. Разрешение имен
        2.1. Сокрытие переменных во вложенных областях видимости
        2.2. Перегрузка функций
    3. Конструкторы, деструкторы, инициализация, удаление
        3.1. Функции-члены класса, генерируемые компилятором
        3.2. Неинициализированные переменные
        3.3. Порядок инициализации базовых классов и нестатических членов класса
        3.4. Порядок инициализации статических членов класса и глобальных переменных
        3.5. Исключения в деструкторах
        3.6. Удаление динамических объектов и массивов
        3.7. Удаление при неполном объявлении класса
    4. Операторы, выражения
        4.1. Приоритет операторов
        4.2. Перегрузка операторов
        4.3. Порядок вычисления подвыражений
    5. Виртуальные функции
        5.1 Переопределение виртуальных функций
        5.2 Перегрузка и использование параметров по умолчанию
        5.3 Вызов виртуальных функций в конструкторе и деструкторе
        5.4 Виртуальный деструктор
    6. Непосредственная работа с памятью
        6.1 Выход за границу буфера
        6.2 Z-terminated строки
        6.3 Функции с переменным числом параметров
    7. Синтаксис
        7.1 Сложные объявления
        7.2 Неоднозначность синтаксиса
    8. Разное
        8.1 Ключевое слово inline и ODR
        8.2 Заголовочные файлы
        8.3 Инструкция switch
        8.4 Передача параметров по значению
        8.5 Управление ресурсами
        8.6 Владеющие и невладеющие ссылки
        8.7 Двоичная совместимость
        8.8 Макросы
    9. Итоги
    Список литературы



                        Praemonitus, praemunitus.
                        Предупрежден — значит вооружен. (лат.)



Введение


В С++ достаточно много особенностей, которые можно считать потенциально опасными — при просчетах в проектировании или неаккуратном кодировании они легко могут привести к ошибкам. Часть из них можно списать на трудное сишное детство, часть на устаревший стандарт С++98, но другие уже связаны с особенностями современного С++. Рассмотрим основные из них и попробуем дать совет, как уменьшить их негативное влияние.



1. Типы



1.1. Условные инструкции и операторы


Необходимость совместимости с С приводят к тому, что в инструкции if(...) и аналогичных можно подставлять любое числовое выражение или указатель, а не только выражения типа bool. Проблема усугубляется неявным преобразованием от bool к int в арифметических выражениях и приоритетом некоторых операторов. Это приводит, например, к таким ошибкам:


if(a=b), когда правильно if(a==b),
if(a<x<b), когда правильно if(a<x && x<b),
if(a&x==0), когда правильно if((a&x)==0),
if(Foo), когда правильно if(Foo()),
if(arr), когда правильно if(arr[0]),
if(strcmp(s,r)), когда правильно if(strcmp(s,r)==0).


Некоторые из этих ошибок вызывают предупреждение компилятора, но не ошибку. Также иногда могут помочь анализаторы кода. В С# такие ошибки почти невозможны, инструкция if(...) и аналогичные требуют тип bool, смешивать bool и числовые типы в арифметических выражениях нельзя.


Как бороться:


  • Программировать без предупреждений. К сожалению, это помогает не всегда, часть из описанных выше ошибок не дают предупреждений.
  • Использовать статические анализаторы кода.
  • Старинный сишный прием: при сравнении с константой ставить ее слева, например if(MAX_PATH==x). Выглядит довольно кондово (и даже имеет свое название — «Yoda notation»), да и помогает в небольшом числе из рассмотренных случаев.
  • Как можно шире использовать квалификатор const. Опять же, помогает далеко не всегда.
  • Приучить себя писать правильные логические выражения: if(x!=0) вместо if(x). ( Хотя тут можно попасть в ловушку приоритетов операторов, см. третий пример.)
  • Быть предельно внимательным.


1.2. Неявные преобразования типа (implicit conversions)


С++ относится к языкам со строгой типизации, но довольно широко используются неявные преобразования типа, позволяющие сделать код более коротким. Эти неявные преобразования могут в ряде случаев привести к ошибкам.


Самые неприятные неявные преобразования — это преобразования числового типа или указателя к bool и от bool к int. Именно эти преобразования (необходимые для совместимости с С) и вызывают проблемы, описанные в разделе 1.1. Также не всегда уместны неявные преобразования, потенциально вызывающие потерю точности числовых данных (сужающие преобразования), например от double к int. Во многих случаях компилятор выдает предупреждение (особенно когда может быть потеря точности числовых данных), но предупреждение — это не ошибка. В C# преобразования между числовыми типами и bool запрещены (даже явные), а преобразования, потенциально вызывающие потерю точности числовых данных, почти всегда являются ошибкой.


Программист может добавить еще другие неявные преобразования: (1) определением конструктора с одним параметром без ключевого слова explicit; (2) определением оператора преобразования типа. Эти преобразования пробивают дополнительные бреши в защите, основанной на принципах строгой типизации.


В C# число встроенных неявных преобразований значительно меньше, пользовательские неявные преобразования надо объявлять с помощью ключевого слова implicit.


Как бороться:


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


2. Разрешение имен



2.1. Сокрытие переменных во вложенных областях видимости


В С++ действует следующее правило. Пусть


// Блок А

{
    int x;
    // ...
// Блок Б, вложен в А
    {
        int x;
        // ...
    }
}

По правилам С++ переменная х, объявленная в Б, скрывает (hide) переменную х, объявленную в А. Первое объявление x не обязательно должно быть в блоке: это может быть член класса или глобальная переменная, просто она должна быть видима в блоке Б.


Представим теперь ситуацию, когда надо рефакторить следующий код


// Блок А
{
    int x;
    // ...
// Блок Б, вложен в А
    {
    // что-то делается с х из А

    }
}

По ошибке вносятся изменения:


// Блок Б
{
    // новый код, ошибочный:
    int x;
    // что-то делается с х из Б
    // ...
    // старый код:
    // что-то делается с х из А
}

А вот теперь код «что-то делается с х из А» будет что-то делать с х из Б! Понятно, что все работает не так, как раньше, и найти, в чем дело часто очень не просто. Не зря в С# скрывать локальные переменные запрещено (правда члены класса можно). Отметим, что механизм сокрытия переменных в том или ином варианте используется практически во всех языках программирования.


Как бороться:


  • Объявлять переменные в максимально узкой области видимости.
  • Не писать длинных и глубоко вложенных блоков.
  • Использовать сoding conventions для визуального различения идентификаторов разной области видимости.
  • Быть предельно внимательным.


2.2. Перегрузка функций


Перегрузка функций является неотъемлемой чертой многих языков программирования и С++ не исключение. Но эту возможность надо использовать продуманно, иначе можно натолкнуться на неприятности. В ряде случаев, например, при перегрузке конструктора, у программиста нет выбора, но в других случаях отказ от перегрузки может быть вполне оправдан. Рассмотрим проблемы, возникающие при использовании перегруженных функций.


Если пытаться рассматривать все возможные варианты, которые могут возникнуть при разрешении перегрузки, то правила разрешения перегрузки оказываются весьма сложными, а значит, трудно предсказуемыми. Дополнительную сложность вносят шаблонные функции и перегрузка встроенных операторов. С++11 добавил проблемы с rvalue ссылками и списками инициализации.


Проблемы может создать алгоритм поиска кандидатов на разрешение перегрузки во вложенных областях видимости. Если компилятор нашел в текущей области видимости каких-то кандидатов, то дальнейший поиск прекращается. Если найденные кандидаты оказываются не подходящими, конфликтующими, удаленными или недоступными, выдается ошибка, но попытка дальнейшего поиска не делается. И только, если в текущей области видимости никаких кандидатов нет, поиск переходит в следующую, более широкую область видимости. Работает механизм сокрытия имен, практически такой же, как и рассмотренный в разделе 2.1, см. [Dewhurst].


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


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


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


C# также поддерживает перегрузку функций, но правила разрешения перегрузок немного иные.


Как бороться:


  • Не злоупотреблять перегрузкой функций, а также проектированием функций с параметрами по умолчанию.
  • Если функции перегружаются, то использовать сигнатуры, не вызывающие сомнений при разрешении перегрузки.
  • Не объявлять одноименные функции во вложенных областях видимости.
  • Не забывать, что появившийся в С++11 механизм удаленных функций (=delete), может быть использован для запрета тех или иных вариантов перегрузки.


3. Конструкторы, деструкторы, инициализация, удаление



3.1. Функции-члены класса, генерируемые компилятором


Если программист не определил функции-члены класса из следующего списка — конструктор по умолчанию, копирующий конструктор, оператор копирующего присваивания, деструктор, — то компилятор может сделать это за него. С++11 добавил к этому списку перемещающий конструктор и оператор перемещающего присваивания. Эти функции-члены называются специальные функции-члены. Они генерируются, только если они используются, и выполняются дополнительные условия, специфичные для каждой функции. Обратим внимание, на то, что это использование может оказаться достаточно скрытым (например, при реализации наследования). Если требуемая функция не может быть сгенерирована, выдается ошибка. (За исключением перемещающих операций, они заменяются на копирующие.) Генерируемые компилятором функции-члены являются открытыми и встраиваемыми. Подробности о специальных функциях-членах можно найти в [Meyers2].


В ряде случаев такая помощь со стороны компилятора может оказаться «медвежьей услугой». Отсутствие пользовательских специальных функций-членов может привести к созданию тривиального типа, а это, в свою очередь, вызывает проблему неинициализированных переменных, см. раздел 3.2. Генерируемые функции-члены являются открытыми, а это не всегда согласуется с дизайном классов. В базовых классах конструктор должен быть защищенным, иногда для более тонкого управления жизненным циклом объекта нужен защищенный деструктор. Если класс имеет в качестве члена сырой дескриптор ресурса и владеет этим ресурсом, то программисту необходимо реализовывать копирующий конструктор, оператор копирующего присваивания и деструктор. Хорошо известно так называемое «правило большой тройки», которое утверждает, что если программист определил хотя бы одну из трех операций — копирующий конструктор, оператор копирующего присваивания или деструктор, — то он должен определить все три операции. Генерируемые компилятором перемещающий конструктор и оператор перемещающего присваивания — также далеко не всегда то, что нужно. Генерируемый компилятором деструктор в некоторых случаях приводит к весьма тонким проблемам, следствием которых может быть утечка ресурсов, см. раздел 3.7.


Программист может запретить генерацию специальных функций-членов, в С++11 надо применить при объявлении конструкцию "=delete", в С++98 объявить соответствующую функцию-член закрытой и не определять.


Если программиста устраивает функции-члены, генерируемые компилятором, то в С++11 он может обозначить это явно, а не просто опустив объявление. Для этого при объявлении надо использовать конструкцию "=default", код при этом лучше читается и появляется дополнительные возможности, связанные с управлением уровнем доступа.


В C# компилятор может генерировать конструктор по умолчанию, обычно это никаких проблем не вызывает.


Как бороться:


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


3.2. Неинициализированные переменные


Конструкторы и деструкторы можно назвать ключевыми элементами объектной модели С++. При создании объекта обязательно вызывается конструктор, а при удалении — деструктор. Но проблемы совместимости с С вынудили сделать некоторое исключение, и это исключение называется тривиальные типы. Они введены для моделирования сишных типов и сишного жизненного цикла переменных, без обязательного вызова конструктора и деструктора. Сишный код, если он компилируется и выполняется в С++, должен работать также как в С. К тривиальным типам относятся числовые типы, указатели, перечисления, а также классы, структуры, объединения и массивы, состоящие из тривиальных типов. Классы и структуры должны удовлетворять некоторым дополнительным условиям: отсутствие пользовательского конструктора, деструктора, копирования, виртуальных функций. Для тривиального класса компилятор может сгенерировать конструктор по умолчанию и деструктор. Конструктор по умолчанию обнуляет объект, деструктор ничего не делает. Но этот конструктор будет сгенерирован и использован, только, если он явно вызывается при инициализации переменной. Переменная тривиального типа будет неинициализированной, если не использовать какой-нибудь вариант явной инициализации. Синтаксис инициализации зависит от типа и контекста объявления переменной. Статические и локальные переменные инициализируются при объявлении. Для класса непосредственные базовые классы и нестатические члены класса инициализируются в списке инициализации конструктора. (C++11 позволяет инициализировать нестатические члены класса при объявлении, см. далее.) Для динамических объектов выражение new T() создает объект, инициализированный конструктором по умолчанию, а вот new T для тривиальных типов создает неинициализированный объект. При создании динамического массива тривиального типа, new T[N], его элементы всегда будут неинициализированы. Если создается или расширяется экземпляр std::vector<T> и не предусмотрены параметры для явной инициализации элементов, то для них гарантируется вызов конструктора по умолчанию. В C++11 появился новый синтаксис инициализации — с помощью фигурных скобок. Пустая пара скобок означает инициализацию с помощью конструктора по умолчанию. Такая инициализация возможна везде, где используется традиционная инициализация, кроме этого стало возможным инициализировать нестатические члены класса при объявлении, которая заменяет инициализацию в списке инициализации конструктора.


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


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


В стандартной библиотеке С++11 есть шаблоны, называемые свойствами типов (заголовочный файл <type_traits>). Один из них позволяет определить, является ли тип тривиальным. Выражение std::is_trivial<Т>::value имеет значение true, если T тривиальный тип и false в противном случае.


Сишные структуры также часто называют Plain Old Data (POD). Можно считать, что POD и «тривиальный тип» являются практически эквивалентными терминами.


В C# неинициализированные переменные вызывают ошибку, это контролирует компилятор. Поля объектов ссылочного типа инициализируются по умолчанию, если не выполнена явная инициализация. Поля объектов значимого типа инициализируются либо все по умолчанию, либо все должны быть инициализированы явно.


Как бороться:


  • Иметь привычку явно инициализировать переменную. Неинициализированная переменная должна «резать глаз».
  • Объявлять переменные в максимально узкой области видимости.
  • Использовать статические анализаторы кода.
  • Не проектировать тривиальных типов. Для того, чтобы тип не был тривиальным, достаточно определить пользовательский конструктор.


3.3. Порядок инициализации базовых классов и нестатических членов класса


При реализации конструктора класса инициализируются непосредственные базовые классы и нестатические члены класса. Порядок инициализации определяет стандарт: сначала базовые классы в том порядке, в котором они объявлены в списке базовых классов, после этого нестатические члены класса в порядке объявления. При необходимости явной инициализации базовых классов и нестатических членов используется список инициализации конструктора. К сожалению, элементы этого списка не обязаны находится в порядке, в котором происходит инициализация. Это надо учитывать, если при инициализации элементы списка используют ссылки на другие элементы списка. При ошибке ссылка может быть на еще не инициализированный объект. C++11 позволяет инициализировать нестатические члены класса при объявлении (используя фигурные скобки). В этом случае их не надо инициализировать в списке инициализации конструктора и проблема частично снимается.


В C# объект инициализируется следующим образом: сначала инициализируются поля, от базового подобъекта до последнего производного, затем вызываются конструкторы в том же порядке. Описанная проблема не возникает.


Как бороться:


  • Поддерживать список инициализации конструктора в порядке объявления.
  • Стараться делать инициализацию базовых классов и членов класса независимой.
  • Использовать инициализацию нестатических членов при объявлении.


3.4. Порядок инициализации статических членов класса и глобальных переменных


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


Как бороться:


  • Принимать специальные меры для предотвращения такой ситуации. Например, использовать локальные статические переменные (синглтоны), они инициализируются при первом использовании.


3.5. Исключения в деструкторах


Деструктор не должен выбрасывать исключений. При нарушении этого правила можно получить неопределенное поведение, чаще всего аварийное завершение.


Как бороться:


  • Не допускать выброса исключения в деструкторе.


3.6. Удаление динамических объектов и массивов


Если создается динамический объект некоторого типа T


T* pt = new T(/* ... */);

то он удаляется оператором delete


delete pt;

Если создается динамический массив


T* pt = new T[N];

то он удаляется оператором delete[]


delete[] pt;

Если не соблюдать это правило, можно получить неопределенное поведение, то есть может случиться все, что угодно: утечка памяти, аварийное завершение и т.д. Подробнее см. [Meyers1].


Как бороться:


  • Использовать правильную форму delete.


3.7. Удаление при неполном объявлении класса


Определенные проблемы может создать «всеядность» оператора delete, его можно применить к указателю типа void* или к указателю на класс, который имеет неполное (упреждающее) объявление. Оператор delete, примененный к указателю на класс — это двухфазная операция, сначала вызывается деструктор, потом освобождается память. В случае применения оператора delete к указателю на класс с неполным объявлением ошибки не возникает, компилятор просто пропускает вызов деструктора (правда выдается предупреждение). Рассмотрим пример:


class X; // неполное объявление
X* CreateX();

void Foo()
{
     X* p = CreateX();
     delete p;
}

Этот код компилируется, даже, если в точке вызова delete не доступно полное объявление класса X. Visual Studio выдает следующее предупреждение:

warning C4150: deletion of pointer to incomplete type 'X'; no destructor called


Если есть реализация X и CreateX(), то код компонуется, если CreateX() возвращает указатель на объект, созданный оператором new, то вызов Foo() успешно выполняется, деструктор при этом не вызывается. Понятно, что это может привести к утечке ресурсов, так что еще раз о необходимости внимательно относится к предупреждениям.


Ситуация эта не надумана, она может возникнуть при использовании классов типа интеллектуального указателя или классов-дескрипторов. Возникновение такой ситуации может стимулировать деструктор, генерируемый компилятором. Стандартные интеллектуальные указатели защищены от такой ошибки, поэтому компилятор выдаст сообщение об ошибке, но самодельные классы, типа интеллектуального указателя, могут ограничиться предупреждением. Скотт Мейерс разбирается с этой проблемой в [Meyers2].


Как бороться:


  • Программировать без предупреждений.
  • Объявлять деструктор явно и определять его в области видимости полного объявления класса.
  • Использовать проверку на этапе компиляции.


4. Операторы, выражения



4.1. Приоритет операторов


Операторов в С++ много, их приоритет не всегда очевиден. Не надо забывать и про ассоциативность. И не всегда компилятор обнаруживает подобную ошибку. Ситуация усугубляется проблемами, описанными в разделе 1.1.


Приведем пример:


std::сout<<c?x:y;

На самом деле это довольно бессмысленная инструкция


(std::сout<<c)?x:y;

а не


std::сout<<(c?x:y);

как, скорее всего, ожидает программист.


Все приведенные выше инструкции компилируются без ошибок и предупреждений. Проблема в этом примере заключается в неожиданно более высоком приоритете оператора << по сравнению с приоритетом оператора ?: и наличии неявного преобразования от std::сout к void*. В С++ нет специального оператора записи данных в поток и приходится прибегать к перегрузке, при которой приоритет не меняется. По-хорошему, приоритет оператора записи данных в поток должен быть очень низким, на уровне оператора присваивания. Низкий приоритет оператора ?: может создать проблемы и других случаях. Фактически его надо заключать в скобки всегда, когда он является подвыражением (кроме простейшего присваивания).


Вот другой пример: выражение x&f==0 на самом деле x&(f==0), а не (x&f)==0, как, скорее всего, ожидает программист. У операторов побитовых операций почему-то низкий приоритет, хотя, с точки зрения здравого смысла, по приоритету они должны находится в группе арифметических операторов, перед операторами сравнения.


Еще пример. Умножение/деление целых чисел на степень двойки можно заменить на побитовый сдвиг. Но умножение/деление имеют более высокий приоритет, чем сложение/вычитание, а сдвиг более низкий. Поэтому, если мы заменили выражение x/4+1 на x>>2+1, то получим x>>(2+1), а не (x>>2)+1, как нужно.


C# имеет практически такой же набор операторов, как и C++, с такими же приоритетами и ассоциативностью, но проблем меньше из-за более строгой типизации и правил перегрузки.


Как бороться:


  • Не жалеть скобок, ставить их при малейшем сомнении. Это, кстати, часто улучшает читаемость кода.


4.2. Перегрузка операторов


С++ позволяет перегрузить почти все операторы, но пользоваться этой возможностью надо осторожно. Смысл перегруженного оператора должен быть очевиден для пользователя. Не надо забывать о приоритете и ассоциативности операторов, они при перегрузке не меняются и должны соответствовать ожиданиям пользователя, см. раздел 4.1. Хороший пример перегрузки — это использование операторов + и += для конкатенации строк. Некоторые операторы перегружать не рекомендуется. Например, следующие три оператора: , (запятая), &&, ||. Дело в том, что для них стандарт предусматривает порядок вычисления операндов (слева-направо), а для последних двух еще и так называемую семантику быстрых вычислений (short-circuit evaluation semantics), но для перегруженных операторов это уже не гарантируется, что может оказаться весьма неприятной неожиданностью для программиста. Также не рекомендуется перегружать оператор & (взятие адреса). Тип с перегруженным оператором & опасно использовать с шаблонами, т.к. они могут использовать стандартную семантику этого оператора.


Почти все операторы можно перегружать в двух вариантах, как оператор-член и как свободный (не-член) оператор, причем одновременно. Такой стиль может сильно осложнить жизнь.


Если все-таки перегрузка делается, надо соблюдать ряд правил, зависящих от перегружаемого оператора. Подробнее см. [Dewhurst].


C# также поддерживает перегрузку операторов, но правила перегрузки более строгие, соответственно, потенциальных проблем меньше.


Как бороться:


  • Тщательно продумывать перегрузку операторов.
  • Не перегружать не рекомендованные для перегрузки операторы.


4.3. Порядок вычисления подвыражений


Стандарт С ++ в общем случае не определяет порядок вычисления подвыражений в сложном выражении, в том числе порядок вычисления аргументов при вызове функции. (Исключением являются четыре оператора: ,(запятая), &&, ||, ?:.) Это может привести к тому, что выражения, скомпилированные разными компиляторами, будут иметь разные значения. Вот пример такого выражения:


int x=0;
int y=(++x*2)+(++x*3);

Значение y зависит от порядка вычисления инкрементов.


Если при вычислении подвыражения выбрасывается исключение, то при неблагоприятных условиях может возникнуть утечка ресурсов. Вот пример.

class X;
class Y;

void Foo(std::shared_ptr<X>, std::shared_ptr<Y>);

Пусть Foo() вызывается следующим образом:


Foo(std::shared_ptr<X>(new X()), std::shared_ptr<Y>(new Y()));

Пусть аргументы вычисляются следующим образом: конструктор X, конструктор Y, конструктор std::shared_ptr<X>, конструктор std::shared_ptr<Y>. Если конструктор Y выбрасывает исключение, то экземпляр X не будет удален.


Правильный код можно написать так:


auto p1 = std::shared_ptr<X>(new X());
auto p2 = std::shared_ptr<Y>(new Y());
Foo(p1, p2);

Еще лучше использовать шаблон std::make_shared<Y> (но у него есть ограничения, он не поддерживает пользовательских удалителей):


Foo(std::make_shared<X>(), std::make_shared<Y>());

Подробнее см. [Meyers2].


Как бороться:


  • Продумывать построение сложных выражений.


5. Виртуальные функции



5.1. Переопределение виртуальных функций


В С++98 переопределение производится, если функция в производном классе совпадает с виртуальной по имени (кроме деструктора), параметрам, константности и возвращаемому значению (на возвращаемое значение есть некоторое послабление, называемое ковариантными возвращаемыми значениями). Дополнительную путаницу вносит ключевое слово virtual, его можно использовать, а можно и опустить. При ошибке (элементарной опечатке), переопределение не происходит, иногда выдается предупреждение, но часто это происходит молча. Естественно, программист получает совсем не то, что задумал. К счастью, в С++11 появилось ключевое слово override, которое значительно облегчает жизнь, все ошибки выявляет компилятор, к тому же читаемость кода заметно улучшается. Но старый стиль переопределения виртуальных функций оставлен для обратной совместимости.


Как бороться:


  • Использовать ключевое слово override.
  • Использовать чисто виртуальные функции. Если такая не переопределяется, то компилятор это обнаруживает при попытке создать экземпляр класса.


5.2. Перегрузка и использование параметров по умолчанию


Следует очень осторожно использовать перегрузку и параметры по умолчанию для виртуальных функций. Дело в том, что разрешение перегрузки и актуализация параметров по умолчанию делается на основе статического типа переменной, для которой вызывается виртуальная функция. Это не согласуется с динамической природой виртуальных функций и может привести к довольно неожиданным результатам. Детали и примеры см. [Dewhurst].


Как бороться:


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


5.3. Вызов виртуальных функций в конструкторе и деструкторе


Иногда, при проектировании полиморфной иерархии классов, возникает потребность выполнение полиморфной операции при создании или уничтожении объекта. Например, операций, которые можно условно назвать post_construct или pre_destroy. Первое, что может придти в голову — это вставить вызов виртуальной функции в конструктор или деструктор. Но это будет ошибкой. Дело в том, что в конструкторе и деструкторе полиморфизм не работает: всегда вызывается функция переопределенная (или унаследованная) для соответствующего класса. (И, соответственно, эта функция может оказаться чисто виртуальной.) Если бы это не выполнялось, то виртуальная функция вызывалась бы для еще не созданного объекта (в конструкторе), или уже уничтоженного (в деструкторе). Подробнее см. [Dewhurst]. Отметим, что вызов виртуальной функции может быть спрятан внутри другой, невиртуальной функции.


Один из вариантов решения этой проблемы — использование функции-фабрики для создания объекта и специальной виртуальной функции для удаления.


Интересно, что в C# виртуальная функция, вызванная в конструкторе базового класса, является виртуальной функцией, переопределенной в конце цепочки наследования. В C# объект инициализируется следующим образом: сначала инициализируются поля, от базового подобъекта до последнего производного, затем вызываются конструкторы в том же порядке. Таким образом, такая виртуальная функция может быть вызвана для частично инициализированного объекта (поля инициализированы, а конструктор не вызывался).


Как бороться:


  • Не вызывать виртуальные функции в конструкторе и деструкторе, в том числе и косвенно, через другую функцию.


5.4. Виртуальный деструктор


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


Как бороться:


  • Объявлять деструктор базового класса виртуальным.


6. Непосредственная работа с памятью


Возможность непосредственно работать с памятью через указатели — это одна из ключевых особенностей C/C++, но она же является одной из самых опасных. Малозаметная ошибка и код начинает работать за границами отведенной ему памяти. Наиболее деструктивные последствия вызывают такие ошибки при записи. В народе их называют «стрельба по памяти».


В C# непосредственная работа с памятью возможна только в unsafe mode, который по умолчанию отключен.



6.1. Выход за границу буфера


В стандартной библиотеке С/С++ много функций, которые могут записывать данные за границу целевого буфера: strcpy(), strcat(), sprinf(), etc. Контейнеры стандартной библиотеки (std::vector<>, etc.) в ряде случаев не контролируют выход за границу буфера, отведенного для хранения данных. (Правда, можно работать с так называемой отладочной версией стандартной библиотеки, где осуществляется более жесткий контроль за доступом к данным, естественно за счет снижения эффективности. См. Checked Iterators в MSDN.) Подобные ошибки иногда могут оставаться незамеченными, но могут давать и непредсказуемые результаты: если буфер стековый, то может случиться все что угодно, например программа может молча исчезнуть; если буфер динамический или глобальный, то может возникнуть ошибка защиты памяти.


В C#, если отключен unsafe mode, гарантируется отсутствие ошибок доступа к памяти.


Как бороться:


  • Использовать объектный вариант строки, вектора.
  • Использовать отладочную версию стандартных контейнеров.
  • Использовать для z-terminated строк безопасные функции, они имеют суффикс _s (см. соответствующие предупреждения компилятора).


6.2. Z-terminated строки


Если в такой строке теряется терминальный ноль, то беда. А потерять его можно, например так:


strncpy(dst,src,n);

Если strlen(src)>=n, то dst окажется без терминального нуля (конечно, если не позаботиться об этом дополнительно). Даже если терминальный ноль не теряется, легко записать данные за границу целевого буфера, см. предыдущий раздел. Не стоит забывать про проблему эффективности — поиск терминального нуля делается сканированием всей строки. Заведомо эффективнее if(*str), чем if(strlen(str)>0), а при большом количестве длинных строк разница может оказаться очень существенной. Почитайте притчу о маляре Шлемиле у Джоэла Спольски [Spolsky].


В C# тип string работает абсолютно надежно и максимально эффективно.


Как бороться:


  • Использовать объектный вариант строки.
  • Использовать для работы с z-terminated строками безопасные функции, они имеют суффикс _s (см. соответствующие предупреждения компилятора).


6.3. Функции с переменным числом параметров


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


В C# есть похожие на printf функции, но они работают более надежно.


Как бороться:


  • Избегать по возможности таких функций. Например, вместо printf-подобных функций использовать потоки ввода/вывода.
  • Быть предельно внимательным.


7. Синтаксис



7.1. Сложные объявления


В С++ довольно своеобразный синтаксис объявлений указателей, ссылок, массивов и функций, в результате чего легко написать весьма неудобочитаемые объявления. Вот пример:


const int N = 4, M = 6;

int x,                 // 1
    *px,               // 2
    ax[N],             // 3
    *apx[N],           // 4
    F(char),           // 5
    *G(char),          // 6
    (*pF)(char),       // 7
    (*apF[N])(char),   // 8
    (*pax)[N],         // 9
    (*apax[M])[N],     // 10
    (*H(char))(long);  // 11

На русском языке эти переменные можно описать так:


  1. переменная типа int;
  2. указатель на int;
  3. массив размера N элементов типа int;
  4. массив размера N элементов типа указатель на int;
  5. функция, принимающая char и возвращающая int;
  6. функция, принимающая char и возвращающая указатель на int;
  7. указатель на функцию, принимающую char и возвращающую int;
  8. массив размера N элементов типа указатель на функцию, принимающую char и возвращающую int;
  9. указатель на массив размера N элементов типа int;
  10. массив размера M элементов типа указатель на массив размера N элементов типа int;
  11. функция, принимающая char и возвращающая указатель на функцию, принимающую long и возвращающую int.

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


Во многих примерах символ * можно заменить на & и тогда получится объявление ссылки. (Но нельзя объявить массив ссылок.)


Такие объявления можно упростить с помощью промежуточных typedef (или using-псевдонимов). Например, последнее объявление можно переписать в таком виде:


typedef int(*P)(long);
P H(char);

Определенный навык расшифровки подобных объявлений нужен, но злоупотреблять этим не стоит.


В C# немного другой синтаксис объявлений, такие примеры не возможны.


Как бороться:


  • Использовать промежуточные псевдонимы.


7.2. Неоднозначность синтаксиса


В ряде случаев компилятор не может однозначно определить семантику некоторых инструкций. Пусть есть некоторый класс


class X
{
public:
    X(int val = 0);
// ...
};

В этом случае инструкция


X x(5);

является определением переменной x типа X, инициализированной значением 5. А вот инструкция


X x();

является объявлением функции x, возвращающей значение типа X и не принимающей параметров, а не определением переменной x типа X, инициализированной значением по умолчанию. Для определения переменной типа X, инициализированной значением по умолчанию, надо выбрать один из вариантов:


X x;
X x = X();
X x{};    // только в C++11

Это старая проблема, и еще тогда решили, что если конструкция может интерпретироваться как определение и как объявление, то выбирается объявление. Более сложные примеры описанной проблемы можно найти в [Sutter].


Обратим внимание, на то, что в C++ функции можно объявлять локально (хотя такой стиль трудно отнести к распространенным). Определение таких функций должно быть в глобальной области видимости. (Локальные определения функций в C++ не поддерживаются.)


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


В C# такой проблемы нет, функции могут быть объявлены только в области видимости класса, а синтаксис определения переменных несколько иной.


Как бороться:


  • Помнить про эту проблему.


8. Разное



8.1. Ключевое слово inline и ODR


Многие программисты считают, что ключевое слово inline — это просьба к компилятору встроить по возможности тело функции непосредственно в точке вызова. Но оказывается, что это еще не все. Ключевое слово inline влияет на реализацию компилятором и компоновщиком правила одного определения (One Defenition Rule, ODR). Рассмотрим пример. Пусть в двух файлах определены две функции с одинаковым именем и сигнатурой, но разными телами. При компиляции и компоновке компоновщик выдаст ошибку о дублировании символа, работает ODR. Добавим к определению функций ключевое слово static: ошибки теперь не будет, каждый файл будет использовать свою версию функции, работает локальное связывание. Теперь заменим static на inline. Компиляция и компоновка проходят без ошибки, но оба файла будут использовать одну версию функции, работает ODR, но уже в другом варианте. Понятно, что это может оказаться весьма неприятным сюрпризом. Аналогично обрабатываются функции-члены класса, определенные непосредственно при объявлении класса и шаблонные функции и функции-члены. Но в данном случае вероятность подобной проблемы значительно меньше.


Как бороться:


  • Избегать «голых» inline функций. Стараться определять их в классе или хотя бы в namespace. Это не гарантирует отсутствия такой ошибки, но заметно снижает ее вероятность.
  • Использовать локальное связывание или более прогрессивную технику — анонимные namespace.


8.2. Заголовочные файлы


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


Как бороться:


  • Тщательно продумывать код заголовочных файлов, особенно включение других заголовочных файлов.
  • Использовать технику, уменьшающую зависимость по заголовочным файлам: неполные (упреждающие) объявления, интерфейсные классы и классы-дескрипторы.
  • Никогда не включать в заголовочный файл или перед другими заголовочными файлами using-директиву: using namespace имя, а также using-объявления.
  • Осторожно использовать в заголовочных файлах функции и переменные с локальным связыванием.


8.3. Инструкция switch


Типичная ошибка — отсутствие break в конце ветки case. (Это называется проваливание.) В C# такие ошибки выявляются при компиляции.


Как бороться:


  • Быть предельно внимательным.


8.4. Передача параметров по значению


В С++ программист должен сам решать, как реализовывать передачу параметров функции — по ссылке или по значению, — язык и компилятор никакой помощи не оказывают. Объекты пользовательских типов (объявленные как class или struct) обычно передаются по ссылке, но легко ошибиться и передача будет реализована по значению. (Подобные ошибки могут появляться чаще у тех, кто привык к стилю программирования С# или Java.) Передача по значению — это копирование аргумента, которое может вызвать следующие проблемы.


  1. Копирование объекта почти всегда менее эффективно, чем копирование ссылки. Если тип параметра владеет ресурсом и использует стратегию глубокого копирования (а это std::string, std::vector, etc.), то происходит копирование ресурса, которое обычно совсем не нужно и приводит к дополнительному снижению эффективности.
  2. Если функция изменяет объект, то эти изменения будут сделаны с локальной копией, вызывающий контекст его не увидит.
  3. Если аргумент имеет производный тип по отношению к типу параметра, то происходит так называемая срезка (slicing), вся информация о производном типе теряется, о каком либо полиморфизме говорить не приходится.

Если объект должен изменяться функцией, то параметр надо передавать по ссылке, если нет, то по ссылке на константу. Исключение всегда надо перехватывать по ссылке. Вообще, решения с передачей параметра по значению возможны, но требуются достаточно редко и должны быть тщательно обоснованы. Например, в стандартной библиотеке итераторы и функциональные объекты передаются по значению. При проектировании класса можно запретить передачу экземпляров этого класса по значению. Более грубый способ — объявить копирующий конструктор удаленным (=delete), более тонкий — объявить копирующий конструктор как explicit.


В C# параметры ссылочного типа передаются по ссылке, но для параметров значимого типа программист должен сам контролировать тип передачи.


Как бороться:


  • Быть предельно внимательным, реализовывать правильный тип передачи параметров.
  • При необходимости запрещать передачу параметров по значению.


8.5. Управление ресурсами


В С++ нет средств для автоматического управления ресурсами типа сборщика мусора. Программист должен сам принимать решения, каким образом освобождать неиспользуемые ресурсы. Объектно-ориентированные возможности языка позволяют реализовать необходимые средства (причем часто не одним способом), стандартная библиотека С++11 имеет интеллектуальные указатели, но программист все равно может управлять ресурсами вручную, в сишном стиле, и здесь единственное спасение от утечки ресурсов это внимательность и аккуратность.


Как правильно управлять ресурсами в C++ подробно описано здесь.


В C# есть сборщик мусора, который решает значительную часть проблем управления ресурсами. Правда сборщик мусора не очень подходит для ресурсов, таких как объекты ядра ОС. В этом случае используется ручное или полуавтоматическое (using-блок) управление ресурсами на основе шаблона Basic Dispose.


Как бороться:


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


8.6. Владеющие и невладеющие ссылки


В данном разделе термин «ссылка» будем понимать в широком смысле. Это может быть сырой указатель, интеллектуальный указатель, C++ ссылка, STL-итератор или еще что-нибудь подобное.


Ссылки можно разделить на владеющие и невладеющие. Владеющие ссылки гарантируют наличие объекта на который они ссылаются. Объект не может быть удален, пока доступна хотя бы одна ссылка на него. Невладеющие ссылки такой гарантии не дают. Невладеющая ссылка в любой момент может стать «висячей», то есть ссылаться на удаленный объект. В качестве примера владеющих ссылок можно привести указатели на COM- интерфейсы и интеллектуальные указатели стандартной библиотеки. (Конечно, если использовать их правильно.) Но не смотря на определенную опасность, невладеющие ссылки в C++ используются достаточно широко. И один из основных примеров — это контейнеры и итераторы стандартной библиотеки. Итератор стандартной библиотеки это типичный пример невладеющий ссылки. Контейнер может быть удален и итератор ничего не будет знать про это. Более того, итератор может стать не действительным («висячим» или указывающим на другой элемент) еще при жизни контейнера, в результате изменения его внутренней структуры. Но программисты работают с этим уже десятки лет.


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


Как бороться:


  • Использовать владеющие ссылки.
  • При использовании невладеющих ссылок быть внимательным и аккуратным.


8.7. Двоичная совместимость


Стандарт C++ очень мало регламентирует внутреннее устройство объектов, а также другие аспекты реализации: механизм вызова функций, формат таблицы виртуальных функций, реализация механизма исключений. (Даже размер встроенных типов не фиксирован!) Все это определяется платформой и компилятором. Взаимодействие модулей чаще всего осуществляется через заголовочные файлы, которые компилируются отдельно в каждом модуле. Естественно возникают проблемы совместимости модулей, скомпилированными разными компиляторами. Несовместимыми могут оказаться даже модули, скомпилированные одним компилятором, но с разными ключами компиляции. (Например, смещение членов структуры могут быть разными при разных значениях параметра выравнивания.)


Несколько большей двоичной совместимостью обладает C (но все равно не полной), поэтому для C++ модулей в качестве интерфейса часто используются C функции (объявленные в блоке extern "C"). Такие функции одинаково трактуются всеми C/C++ компиляторами.


Для решения проблемы единообразного выравнивания членов структур иногда добавляют члены-заглушки. Можно использовать #pragma-директивы компилятора для управления выравниваем, но они не стандартизированы, зависят от компилятора.


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


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


В C# почти нет проблем двоичной совместимости. Наверное, единственное исключение — это результат маршалинга объектов, но это уже скорее не C#, а аспект взаимодействия C# и C/C++.


Как бороться:


  • Знать про эту проблему и принимать адекватные решения.


8.8. Макросы


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


#define XXL 32

можно написать


const int XXL=32;

или использовать перечисление. Вместо макросов с параметрами можно определять inline функции и шаблоны.


В С# нет макросов (кроме директив условной компиляции).


Как бороться:


  • Не использовать макросы без самой крайней нужды.


9. Итоги


  1. Используйте возможности компилятора для предупреждения ошибок. Настраиваете компилятор на выдачу максимального количества предупреждений. Программируйте без предупреждений. Если у вас несколько десятков предупреждений, то заметить действительно опасное очень не просто.
  2. Используйте статические анализаторы кода.
  3. Не используйте сишные архаизмы. Программируйте на С++ и желательно на современной версии — С++11/14/17.
  4. Программируйте в объектно-ориентированном стиле, используйте объектно-ориентированные библиотеки и шаблоны.
  5. Не используйте слишком сложные и сомнительные конструкции языка.


Список литературы


Список

[Dewhurst]
Дьюхэрст, Стефан К. Скользкие места C++. Как избежать проблем при проектировании и компиляции ваших программ.: Пер. с англ. — М.: ДМК Пресс, 2012.


[Meyers1]
Мейерс, Скотт. Эффективное использование C++. 55 верных способов улучшить структуру и код ваших программ.: Пер. с англ. — М.: ДМК Пресс, 2014.


[Meyers2]
Мейерс, Скотт. Эффективный и современный C++: 42 рекомендации по использованию C++11 и C++14.: Пер. с англ. — М.: ООО «И.Д. Вильямс», 2016.


[Sutter]
Саттер, Герб. Решение сложных задач на C++.: Пер. с англ. — М: ООО «И.Д. Вильямс», 2015.


[Spolsky]
Сполски, Джоэл. Джоэл о программировании.: Пер. с англ. — СПб.: Символ-Плюс, 2008.




Tags:
Hubs:
-6
Comments26

Articles