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



Оглавление


Оглавление

    Введение
    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 Вызов виртуальных функций в конструкторе и деструкторе
    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). Выглядит довольно кондово, да и помогает в небольшом числе из рассмотренных случаев.
  • Как можно шире использовать квалификатор 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. Хороший пример перегрузки — это использование операторов + и += для конкатенации строк. Некоторые операторы перегружать не рекомендуется. Например, следующие три оператора: , (запятая), &&, ||. Дело в том, что для них стандарт предусматривает порядок вычисления операндов (а для последних двух еще и так называемую семантику быстрых вычислений), но для перегруженных операторов это уже не гарантируется, что может оказаться весьма неприятной неожиданностью для программиста. Также не рекомендуется перегружать оператор & (взятие адреса). Тип с перегруженным оператором & опасно использовать с шаблонами, т.к. они могут использовать стандартную семантику этого оператора.


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


Если все-таки перегрузка делается, надо соблюдать ряд правил, зависящих от перегружаемого оператора. Подробнее см. [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(new X(), new Y());

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


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


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

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


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


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


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


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

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


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


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


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

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


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


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

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

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


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

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# есть сборщик мусора, который решает значительную часть проблем управления ресурсами.


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


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

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. Не используйте сишные архаизмы. Программируйте на С++ и желательно на современной версии — С++11/14/17.
  3. Программируйте в объектно-ориентированном стиле, используйте объектно-ориентированные библиотеки и шаблоны.
  4. Не используйте слишком сложные и сомнительные конструкции языка.
  5. Изучайте С#. Это хороший пример разумных ограничений для повышения надежности и читаемости кода.

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


Список

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


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


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


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


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




Комментарии (26)


  1. vilgeforce
    06.11.2018 13:03
    -1

    «if(strcmp(s,r)), когда правильно if(strcmp(s,r)==0)» — почему правильно?


    1. Krepver
      06.11.2018 14:12

      Потому что это два противоположных условия. Код внутри

      if(strcmp(s,r))
      выполняется, когда они не равны, а
      if(strcmp(s,r)==0)
      — когда равны.


      1. vilgeforce
        06.11.2018 14:16
        +1

        Правильно потому что это два противоположных условия? Серьезно? Откуда вы знаете что этот код должен делать?


        1. Krepver
          06.11.2018 14:25
          -3

          Под «правильно» здесь понимается best practice. Довольно часто встречается код

          if(strcmp(s,r)) {
          // когда равны
          }
          

          Или
          if(!strcmp(s,r)) {
          // когда не равны
          }
          


          Этих ошибок бы не было, если писать явно == 0


          1. vilgeforce
            06.11.2018 14:28
            +1

            Вы все еще продолжаете видеть какие-то ошибки в абсолютно корректном коде, мне это странно


            1. Krepver
              06.11.2018 14:35
              -1

              1. rusl002
                06.11.2018 15:56
                -1

                Не могли бы Вы пояснить, в чем собственно ошибка и почему нужно именно
                if(strcmp(s,r) ==0 ), а не просто if(!strcmp(s,r)).


                1. Krepver
                  06.11.2018 16:15
                  -2

                  Человек может ошибиться и прочитать if(!strcmp(s,r)) как «если строки не равны». Best practice как раз и направлены на уменьшение количества таких потенциальных проблем.


    1. Darell_Ldark
      06.11.2018 14:29

      В первом случае идет проверка на то, что первая строка больше второй. Во втором случае идет проверка на равенство строк.
      Говорится о коварности неявного преобразования int в bool. Это может дать не тот результат, что хотелось бы получить.

      Update: впрочем, ответ уже дали. Зависит, конечно, целиком и полностью от контекста применения. Но, наверное, удобнее читать будет, когда в явном виде будет указано возвращаемое значение. То есть, второй вариант.


    1. Amr
      06.11.2018 15:56
      +1

      Возможно автор хотел сказать что if (strcmp(s, r) == 0) наиболее часто использующийся случай для проверки равны ли строки. Я бы наверное оформил это таким образом
      if (strcmp(s, r) ==0 ) — проверить равны ли строки
      if (strcmp(s, r) !=0 ) — проверть не равны ли строки

      А так да, говорить что if(strcmp(s,r)==0) неверно, неверно :)


    1. dm_frox Автор
      06.11.2018 19:18

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


  1. staticmain
    06.11.2018 15:06
    +3

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

    Некоторые из этих ошибок вызывают предупреждение компилятора, но не ошибку. В С# такие ошибки почти невозможны


    Вы влезли в чужой монастырь (С++) со своим уставом (С#). Если вы не знаете, что сравнение if (a=b) приведет к ошибке (а о нем, кстати, говорит компилятор), то вы не знаете С++ и не умеете на нем писать.

    1.2. Неявные преобразования типа (implicit conversions)
    Самые неприятные неявные преобразования — это преобразования числового типа или указателя к bool и от bool к int. Именно эти преобразования (необходимые для совместимости с С) и вызывают проблемы, описанные в разделе 1.1.

    Да что ж вы так взъелись к этим преобразованиям? Опять же, если вы вскормлены С#, то зачем идете в С++, где язык позволяет писать так:?
    if (ptr) {
    
    }
    
    if (a) {
    
    }

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

    Для этого существует namespace, если это вам так уж мешает. В Си это еще было проблемой, для решения которой даже есть два MISRA правила.
    2.2. Перегрузка функций
    Какой ужас! Программисты на С++ могут использовать все возможности ООП и синтаксического сахарка! Ужас! Запретить!
    3.1. Функции-члены класса, генерируемые компилятором
    Да что этот компилятор себе позволяет!
    В ряде случаев такая помощь со стороны компилятора может оказаться «медвежьей услугой». Отсутствие пользовательских специальных функций-членов может привести к созданию тривиального типа, а это, в свою очередь, вызывает проблему неинициализированных переменных

    А просто посмотреть что конкретно вы пишете уже нельзя? Надо обязательно запретить фичу, если из 1000 программистов попался один дурак, который пишет код без тестов.
    3.2. Неинициализированные переменные
    Это было проблемой в годах эдак 90х-00х. Сейчас существуют анализаторы как runtime так и статик кода, наипервейшая фича из которых — сказать что переменная неинициализирована. Да на неинициализированных переменных когда-то (не знаю как сейчас) openssl свои шифры шифровал.
    3.3. Порядок инициализации базовых классов и нестатических членов класса
    Порядок инициализации определяет стандарт

    И чего вам еще нужно? В стандарте написана последовательность. Вы же не бегаете вокруг и не кричите, что вам сложно запомнить, какой оператор имеет более высокий приоритет — & или ~.
    В C# в списке инициализации конструктора может быть либо непосредственный базовый класс, либо перегруженный конструктор, соответственно, описанная проблема не возникает
    B снова ваши С# корни не дают вам покоя. В С# можно функцию в файле где угодно объявить и она будет видима. Что ж вы это в косяки С++ не внесли?

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

    Это надо учитывать

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

    Ой, а что это вы не упомянули, что в C# поведение программы в таком случае зависит от версии .NET? под .NET1 программа будет вести себя не так же как под .NET2 и .NET4.

    3.6. Удаление динамических объектов и массивов
    Какой ужас, запомнить, что массив удаляется delete[].
    3.7. Удаление при неполном объявлении класса
    правда выдается предупреждение

    И чего программисту еще надо? Даже тут компилятор за него подумал.
    4.1. Приоритет операторов
    Операторов в С++ много, их приоритет не всегда очевиден.

    Ужас-ужас, кто-то не посетил второй урок по программированию, на котором рассказывали про приоритет операторов. Давайте я вам плакат подарю.
    4.2. Перегрузка операторов
    Согласен, отвратительно. Давайте создадим петицию чтобы все вместо
    vector_t a;
    vector_t b;
    a += b;

    писали
    vector_t a;
    vector_t b;
    a.supplement(b);
    Это самый примитивный пример. В реальности только вызов нужной функции будет занимать две-три строки.
    4.3. Порядок вычисления подвыражений
    Вот пример такого выражения:

    А ничего, что у вас там UB? Порядок выражения определяется в общем случае скобками. Компилятор будет выстраивать остальной порядок для максимизации выигрыша по скорости. Как раз того, чего в С# зачастую не хватает.
    5.1. Переопределение виртуальных функций
    В С++98 переопределение производится, если функция в производном классе совпадает с виртуальной по имени (кроме деструктора), параметрам, константности и возвращаемому значению (на возвращаемое значение есть некоторое послабление, называемое ковариантными возвращаемыми значениями)
    Точка. Как можно программировать на каком-то языке не думаю о том, что пишешь? Это называется SO-driven development.
    5.2. Перегрузка и использование параметров по умолчанию
    Следует очень осторожно использовать перегрузку и параметры по умолчанию для виртуальных функций.

    Напомнить цитату про лезвие?
    5.3. Вызов виртуальных функций в конструкторе и деструкторе
    Первое, что может придти в голову — это вставить вызов виртуальной функции в конструктор или деструктор. Но это будет ошибкой.

    Ииии… Это проблема? Запомнить что ООП-шная логика применяется в первую очередь к текущему классу трудно?
    6. Непосредственная работа с памятью
    Боже! Запретить указатели! Запретить! Запретить!
    Вот в моем уютном
    В C# непосредственная работа с памятью возможна только в unsafe mode, который по умолчанию отключен.
    не работает и у вас не должно! Ужас! Это же дети смотрят! Забудем, что в ряде случаев это дает буст в +10 000% по скорости. Пусть слак жрет 1.5 ГБ.
    6.1. Выход за границу буфера
    В C#, если отключен unsafe mode, гарантируется отсутствие ошибок доступа к памяти.

    Вот же пристали со своим C#. А вот в Java будет ArrayIndexOutOfBoundsException который можно поймать! Что, съели? Му-ха-ха, теперь С# не так крут, верно?
    Думать нужно о входящих данных, которые в strcpy суются. В нем умышленно нет проверки даже на NULL. Потому что что выгоднее — если один программист не попадется на NULL[5] или то что у миллионов других будет просадка по скорости?
    6.2. Z-terminated строки
    Дайте угадаю, на C# пишут неосиляторы, которые не знаю, что string внутри имеет Z-terminated строку?
    В C# тип string работает абсолютно надежно и максимально эффективно.
    О, угадал.
    6.3. Функции с переменным числом параметров
    Вы просто открыли учебник по С++, прошлись по оглавлению и написали пункты, да? И в C# у вас «особенные» функции, да?
    В C# есть похожие на printf функции, но они работают более надежно.


    7.1. Сложные объявления
    Ну конечно, после C# с его игрушечным синтаксисом сложно, небось, понять все эти ук-хазатели.
    В C# немного другой синтаксис объявлений, такие примеры не возможны.

    7.2. Неоднозначность синтаксиса
    Т.е. вы намеренно объявили класс X с методом X, создали неоднозначность (если не UB, надо уточнять), а теперь жалуетесь, что синтаксис вам такое позволил, да?
    В C# такой проблемы нет

    И в brainfuck такой проблемы тоже нет. И в LISP и даже в (простигосподи) в C.
    8.1. Ключевое слово inline и ODR
    Т.е. почитать учебник прежде чем что-то писать видимо слишком сложно.
    8.2. Заголовочные файлы
    Заголовочные файлы — это ужасно и отвратительно, по возможности старайтесь избегать их. Лучше вообще на С++ не пишите.
    8.3. Инструкция switch
    В C# такие ошибки выявляются при компиляции.

    А давайте я вам напомню, как сделать умышленный fallthrough в C#. Напомнить или постыдитесь?
    switch (/*...*/) {
        case 0: // shares the exact same code as case 1
        case 1:
            // do something
            goto case 2;
        case 2:
            // do something else
            goto default;
        default:
            // do something entirely different
         

    GoTo. GoTo, мать их за ногу! Ничего умнее в MS не придумали?
    8.4. Передача параметров по значению
    Если функция изменяет объект, то эти изменения будут сделаны с локальной копией, вызывающий контекст его не увидит.
    Ужасно, ведь это позволяет экономить на копировании объекта для локальной копии, если делается, например, while(a--).
    8.5. Управление ресурсами
    В С++ нет средств для автоматического управления ресурсами типа сборщика мусора.
    Вот только GC тянуть в С++, не надо, да? Этот ваш C# как JAVA виснет на пару секунд в рандомные моменты как только GC моча в голову ударит что надо мусор собрать. Из-за этого в real time приложения на этом вашем C# нельзя писать в принципе.
    8.6. Владеющие и невладеющие ссылки
    Ога, давайте еще внедрим в каждый указатель класс, который будет проверять наличие объекта, вот круто будет, да? А то, что оно будет отжирать память и процессор, так планки сегодня дешевые. И пофиг, что на 1000 удаленных узлов нужно купить в 1000 раз больше планок памяти, не разорятся, купят.
    8.7. Двоичная совместимость
    Зачем вам двоичная совместимость, а главное, с чем? Если на данной платформе в памяти удобнее (и в 10 раз быстрее доступ для процессора) выравнивать объекты по 8 байт а не по 4 — то что будем делать — всем выравнивать по 8? Ой, наше приложение выжрало в 2 раза больше памяти на x86. Будем выравнивать везде по 4? Ой, на какой-то платформе процессор не смог обратиться к 0x00043664 и программа упала.
    Проблему двоичной совместимости пытались решить, например, при разработке стандартов COM. COM-объекты, используемые в разных модулях, двоично совместимые (даже если написаны на разных языках, не говоря уже о разных компиляторах). Но COM является не очень популярной технологией, к тому же, реализованной не на всех платформах.
    COM? В качестве эталона вы приводите COM? Я бы понял perl-storable, который хоть и в одну сторону, но совместим между версиями. Но, блин, COM? Которым нормальные люди уже лет 15 как не пользуются и который существует только под windows?
    8.8. Макросы
    Да вот кто-то вас уже послушал и хотел когда-то их выкинуть.
    В C++ достаточно много альтернатив макросам. Вместо
    #define XXL 32

    можно написать
    const int XXL=32;

    А ничего, что эти две строки делают В ПРИНЦИПЕ разные вещи? В макросе вы ограничены типов, вы не можете конкатенировать, вы не можете токенизировать в строку. Когда вы объявляете переменную она добавляется в бинарь, выделяясь там в ro-блоке.
    9. Итоги
    Изучайте С#.

    В итоге: как писать на С++: пишите на C#. А что сразу не на JAVA? или на SCALA?


    1. Chaos_Optima
      06.11.2018 15:49
      +2

      Прям с языка снял. Автор поста как бы говорит «Зачем вам С++, смотрите там много сложных моментов, пишите лучше на С#»
      Зачем эти тупые сравнения с С#, непонятно. Всё описанное является в большинстве своём обычным поведение С++ которое проходится по мере изучения. Почему не было расписано про подводные косяки использования лямбд например, когда замыкаешь значение по ссылке а потом выходишь из скоупа, или возврат ссылки или указатела на локальные переменные.
      Да блин в С++ есть гораздо больше тонких мест, на которые действительно можно наткнутся, а в статье большинство примеров это притянутый за уши бред который практически никогда не встречается в реальности.


      1. dm_frox Автор
        06.11.2018 20:09
        -1

        Согласен, что в C++ много еще более тонких моментов. Я описал базовые проблемы. Еще раз, никакой агитации за C# нет, хотелось показать, за счет чего весьма близкий язык является более надежным и удобным. Лично я считаю, что C++ программисту весьма полезно понимать как работают платформы .NET или Java. Большинство примеров не притянуто за уши, я натыкался на них в коде коллег (включая самого себя).


        1. Chaos_Optima
          06.11.2018 23:36

          Эти базовые моменты никому не интересны, они описаны во всех учебниках (хотя сомневаюсь что хоть кто-то подозревает что перегрузка является опасной (sic!)).
          По поводу коллег сочувствую. А по поводу агитации да простите, её там совсем не чувствуется чего это я. (С# является моим 3 любимым языком, но вот с++ для меня является гораздо более удобным, так что это субъективно)


    1. dm_frox Автор
      06.11.2018 19:49
      -1

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


      1. staticmain
        06.11.2018 19:59
        +1

        Никакой агитации за C# нет

        Вы мне напоминаете госчиновника:
        Не зря в С#

        C# также поддерживает

        В C# компилятор может

        В C# в списке

        C# имеет

        Интересно, что в C#

        В C# непосредственная работа

        В C#, если отключен

        В C# тип string работает абсолютно надежно

        В C# есть

        В C# немного другой синтаксис

        В C# такой проблемы нет

        В C# параметры ссылочного типа передаются по ссылке

        В C# есть сборщик мусора

        В С# почти все ссылки

        В C# почти нет проблем

        но это уже скорее не C#

        В С# нет макросов

        Изучайте С#

        image


    1. 0xd34df00d
      06.11.2018 20:52
      +2

      Да на неинициализированных переменных когда-то (не знаю как сейчас) openssl свои шифры шифровал.

      Отвратительно.


      Как random seed для змейки можно использовать, для шифров — точно нет.


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

      Это действительно ужасно и отвратительно, особенно если вы любите темплейты. Ждём модули в плюсах.


  1. Darell_Ldark
    06.11.2018 16:59
    -2

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


    1. Heinhain
      06.11.2018 17:09
      +2

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


    1. 0xd34df00d
      06.11.2018 20:47
      +1

      У C++ есть куча проблем, но из них перечислено, наверное, две (про порядок инициализации статиков и ещё какая-нибудь на всякий, которую я мог упустить по невнимательности).


  1. 0xd34df00d
    06.11.2018 20:46
    +1

    С++ относится к языкам со строгой типизации

    Нет. Это язык со статической типизацией, но она не строга ни в смысле гарантий, ни в смысле выразительности.


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

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


    В C#, кстати, есть rvalue-ссылки и вообще move semantics?


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

    Нет RAII — нет проблем, действительно.


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

    То есть, если вы пишете управляющий ресурсом класс, то его, внезапно, надо сесть и писать. Нетривиальный вывод!


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

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


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

    Не понял логики. А как же порядок объявления членов?


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

    Включить уже, наконец, ворнинги, лет 10 как.


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

    Вот это проблема, да, первая стоящая в этом списке.


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

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


    Как бороться:
    Использовать правильную форму delete.

    Не использовать сырое new. 2018 год на дворе уже, ё-моё.


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

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


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

    А вот точку всё никак нельзя, и это печально :(


    Как бороться:
    Продумывать построение сложных выражений.

    Использовать make_unique и make_shared.


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

    Судя по общему тону до этого, считается, что это в C# сделано типа хорошо?


    Такие функции имеют… в конце списка параметров.

    А ещё туда не-POD-типы передавать нельзя (или non-trivial, я сам не уверен).


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

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


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

    В C# программист тоже может сделать в unsafe-блоке какую-нибудь ерунду, разве нет?


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


    1. dm_frox Автор
      07.11.2018 10:29
      -2

      Хочу еще раз обратить внимание, что никакой агитации за C# нет, просто хотелось показать, за счет чего, весьма близкий язык является более надежным и удобным. Сам я пишу на C++/C# где-то 50 на 50 уже много лет, и люблю оба языка. Считаю, что C++ программисту весьма полезно знать C# (ну на худой конец Java).

      Нет. Это язык со статической типизацией, но она не строга ни в смысле гарантий, ни в смысле выразительности.

      Статическая/динамическая типизация и строгая/нестрогая типизация это разные критерии классификации типизированных языков. Сильная типизация выделяется тем, что язык не позволяет смешивать в выражениях различные типы и не выполняет автоматические неявные преобразования. C++ спроектирован как язык со строгой типизацией, но с рядом послаблений в виде implicit conversions, из-за чего его чисто формально можно отнести к языкам с нестрогой типизацией. Вот статья на эту тему.
      Так и не понял, что именно тут плохо. Какие-то общие слова, что можно делать ошибки. Так, наверное, можно вообще про любую языковую фичу написать в любом языке.

      Программист может написать такие перегрузки, что потом будет пол дня думать, как они будут разрешатся в том или ином контексте. Я рекомендую так не делать. Потом перегруженные функции затрудняют анализ кода, особенно чужого, с помощью текстового поиска.
      В C#, кстати, есть rvalue-ссылки и вообще move semantics?

      Этого в C# нет.
      То есть, если вы пишете управляющий ресурсом класс, то его, внезапно, надо сесть и писать. Нетривиальный вывод!

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

      Я как раз и имел в виду производительность.
      Не понял логики. А как же порядок объявления членов?

      В C# при создании объекта сначала инициализируются все поля объекта (значениями, заданными при объявлении или по умолчанию), затем вызывается конструктор базового класса или перегруженный конструктор, если они указаны в списке инициализации, затем сам конструктор.
      Только это всё же не совсем то. Начиная с C++11 статик-переменные уровня функций инициализируются потокобезопасно, что, конечно, хорошо, но ведёт к очевидной дополнительной плате на синхронизацию при каждом входе в такую функцию.

      За все надо платить. Но в данном случае немного, такой вызов обычно происходит один раз.
      Не использовать сырое new. 2018 год на дворе уже, ё-моё.

      Конечно, new/delete должны быть максимально инкапсулированы. Но полностью избавится от них не реально. C++ программист должен четко знать, как с ними правильно работать.
      Использовать make_unique и make_shared.

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

      Тут я выразился не совсем точно. Описываю ситуацию более подробно. Пусть в C# у нас есть базовый класс и производный. При создании экземпляра производного класса сначала инициализируются все поля базового класса и затем все поля производного. Только после этого вызывается конструктор базового класса и потом конструктор производного. Пусть в базовом классе определена виртуальная функция и она переопределяется в производном. Если в конструктор базового вставить вызов этой виртуальной функции, то вызовется переопределенная, соответственно, для объекта у которого уже инициализированы поля, но еще не вызывался конструктор. Довольно забавно. Если четко понимать, как это работает, то можно использовать.
      В C# программист тоже может сделать в unsafe-блоке какую-нибудь ерунду, разве нет?

      Программист пишет unsafe-блоки на свой страх и риск. Почти все гарантии .NET отменяются.
      Да и говорить о сборщике мусора как о спасении от «значительной части» проблем, как будто единственный существующий ресурс — это память, ну очень примитивно.

      Я же не говорил «всех проблем». Для оставшихся проблем есть интерфейс IDisposable. Конечно, работать с ним не так удобно, как с деструкторам в C++.


  1. izvolov
    08.11.2018 17:29
    +2

    Почти все эти "проблемы" решаются изучением справки к компилятору и включением флагов.


    -Werror
    
    -Wall
    -Wextra
    -Wpedantic
    
    -Wcast-align
    -Wcast-qual
    -Wconversion
    -Wctor-dtor-privacy
    -Wenum-compare
    -Wfloat-equal
    -Wnon-virtual-dtor
    -Wold-style-cast
    -Woverloaded-virtual
    -Wredundant-decls
    -Wsign-conversion
    -Wsign-promo


    1. dm_frox Автор
      09.11.2018 10:12
      -2

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


      1. izvolov
        09.11.2018 11:35

        Это флаги ГЦЦ и Кланга.


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