Привет, меня зовут Максим. Я программист-самоучка, свою первую строчку кода написал еще в 1994 году и на текущий момент принял участие где-то в 10 игровых проектах.
За это время мне пришлось писать на множестве различных языков:
В очередной раз с C++ работал пару месяцев назад, когда взялся разбираться с Unreal Engine. И поразился, сколько еще неизведанного осталось для меня в этом языке. Сколько еще подводных камней и возможностей выстрелить себе в ногу я подзабыл или не нашел при прошлых опытах использования.
Эта статья — попытка осмыслить, почему у C++ такой высокий порог вхождения, чем он уступает другим языкам. А также почему я считаю его плохим языком для программистов-новичков.
Целевая аудитория:
Иллюстрация Ульяны Патоки
Очень надеюсь, что подобранная мною информация окажется полезной и вызовет только конструктивное обсуждение.
Чтобы компилятор получил эту информацию, сначала отрабатывает препроцессор. Он не менее архаичным способом переносит все содержимое каждого включаемого файла в тот, который компилируется. Если в каком-то из этих файлов были включения, они тоже добавляются.
Чтобы не включать избыточую для компилятора информацию (ему код включаемых функций не нужен), договорились все заголовки функций выносить в отдельные файлы, которые и назвали заголовочными. Таким образом возникло разделение, что заголовочные файлы имеют расширение .h (иногда пишут .hpp, чтобы явно указать, что это написано на С++), а файлы с кодом — в файлах с расширением .cpp.
Правда, копирование даже заголовков в другие файлы при каждой их компиляции в большом проекте приводит к тому, что подготовка файлов к компиляции и сам процесс компиляции занимают достаточно много времени. Активное использование шаблонных классов, которые при специализации создают как бы полную копию класса для каждого типа, еще больше усугубляет ситуация с временем компиляции. В результате без специальных ухищрений типа Precompiled Headers, IncrediBuild время компиляции будет на порядок дольше схожих по размеру проектов на других языках. Но даже с ними время компиляции будет значительно уступать.
Приведу пример.
Сейчас я работаю над проектом на C# под Unity, который состоит из 4300 файлов с кодом. Это 25 мегабайт исходников! Время полной компиляции проекта на моем компьютере занимает 10 секунд. Берем пустой проект на Unreal, добавляем единственный объект с С++ классом. Вносим туда малейшее изменение — время компиляций и линковки до запуска, минимум 7 секунд на том же i7-8700.
Но скорость компиляции далеко не единственная «особенность», с которой сталкиваются разработчики на С++ при использовании заголовочных файлов. Существует целый ряд маленьких и не очень проблем-прикольчиков, которые каждый день усложняют им и так непростую жизнь.
1. Так как заголовочные файлы могут включать другие заголовочные файлы, то возможна ситуация, что одно и то же объявление функции будет подставлено более одного раза.
Для компилятора это может быть проблемой, и для ее решения сейчас используют директиву препроцессора #pragma once. Выглядит это так:
// unit.h
#pragma once
void f1(); // пример описания заголовка (сигнатуры) функции f1()
bool f2(int x); //пример описания заголовка (сигнатуры) функции f2()
Но вы можете увидеть и такой вариант решения проблемы, которым пользовались до появления поддержки #pragma once:
// unit.h
#ifndef __UNIT_H__
#define __UNIT_H__
void f1(); // пример описания заголовка (сигнатуры) функции f1()
bool f2(int x); //пример описания заголовка (сигнатуры) функции f2()
#endif
Здесь для каждого файла программист придумывал уникальное имя константы препроцессора. Тогда код между #define и #endif включался только при первой попытке подключить этот заголовочный файл к текущему компилируемому файлу.
Матерые С/С++ программисты даже не посчитают это проблемой, поскольку этот код пишется раз и делается на автомате. Но новичкам приходится запоминать лишнее понятие, которых в этом языке еще ой как много .
2. Второй прикольчик связан с правилом компилятора «все, что выглядит как декларация функции, будет считаться декларацией функции».
С++ представляет много способов создания объектов, и в случае использования круглых скобок только конструктор без параметров не предусматривает использование круглых скобок в угоду обсуждаемому правилу!
ClassA obj1(10); //создание объекта путем вызова конструктора с одним параметром
ClassB obj2(true, 2); //создание объекта путем вызова конструктора с двумя параметрами
ClassC obj3(); //объявление функции без параметров, которая возвращает значение типа ClassC
ClassC obj4; //создание объекта, путем вызова конструктора без параметров
Но проблемы с компилятором легко могут возникнуть и при использовании конструктора с большим числом параметров. Для приведения значения к другому типу С++ представляет два похожих варианта (T)value и T(value). Правда, это работает не всегда. Вопрос к новичкам: как думаете, где вызов конструктора, а где декларация заголовка функции?
int k = 5;
ClassA obj1((float)k);
ClassA obj2(float(k));
В данном случае компилятор посчитает, что obj1 — создание объекта путем вызова конструктора с одним параметром, а obj2 — это объявление функции, которая в качестве параметра получает значение типа float. Как видите, для того чтобы вас поняли правильно, нужно знать немало тонкостей.
3. Добавление новых функций, а также изменение сигнатуры существующих.
Заголовочные файлы приходится создавать почти для каждого класса, и чисто технически сделать это не сложно. Тем более, что в IDE этот процесс автоматизирован.
Но вот заметное неудобство — каждый раз, когда вам понадобиться в класс добавить функцию, придется ее заголовок продублировать в заголовочный файл. Если добавить функцию только в .cpp файл, то о проблеме подскажет компилятор. А вот если добавить заголовок функции в .h файл, но забыть в .cpp — об ошибке вы узнаете от линковщика. Информативностью его сообщения не отличаются.
Простой пример:
// file main.cpp
#include"A.h"
int main()
{
A a;
a.f();//если закомментировать единственное обращение к «забытому» методу, ошибок не будет
return 0;
}
// file A.h
#pragma once
class A
{
public:
A();
~A();
int f();//эту функцию «забыли» добавить в .cpp файл
};
// file A.cpp
#include "A.h"
A::A()
{
}
A::~A()
{
}
Получим такое сообщение об ошибке
main.obj : error LNK2019: unresolved external symbol "public: int __thiscall A::f(void)" (?f@A@@QAEHXZ) referenced in function _main
Со временем к ошибкам линковщика можно привыкнуть, но новичков такие сообщения часто повергают в фрустрацию. Клик по ошибке в IDE не подсвечивает строчку кода с ней. Следует быть внимательным и при изменении сигнатуры метода обязательно вносить изменения в оба файла. Иначе будете постоянно получать сообщения об ошибках, а у компилятора не всегда получается быть очевидным. Эта необходимость держать два файла синхронизированными заставляет постоянно копипастить куски кода, что в мире программирования, как правило, порицается.
4. Преобразование обычного класса в шаблонный.
Достаточно часто в моей практике возникала ситуация, когда сначала создавался обычный класс, а позднее появлялась необходимость преобразовать его в шаблонный, так как находилось еще несколько типов, которые имели тоже самое поведение.
В C# такая трансформация проводится достаточно просто. Например, такой примитивный класс:
Когда появится необходимость для контейнера использовать любой другой тип, достаточно:
Преобразование выразилось в замену стартового типа шаблонным и приписыванием <T> после имени класса.
Теперь посмотрим, что нужно сделать в случае C++. Было:
Для преобразования недостаточно только заменить int типом T. Придется еще много чего дописать (см. выделение цветом), прежде чем компилятор скомпилирует этот класс:
Причина в том, что для шаблонных классов сделано исключение — объявление функций должно быть в том же файле, что и их реализация! Так что для успешного окончания преобразования достаточно содержимое .cpp файла перенести в .h файл.
Но при желании можно все свести к еще более компактному виду. Ничего не напоминает?
Могут ведь, если хотят! Правда, громоздкий вариант выноса определения функций за пределы шаблонного класса тоже часто встречается. Уж не знаю, какие преимущества он дает.
5. Сборка проекта — линковка.
Если вы собираете проект через командную строку, как показывают во многих книгах или на онлайн-курсах, то без ухищрений процесс сборки сложно назвать увлекательным.
Вот как выглядит очередная итерация после внесения изменений в проект:
К счастью, есть еще IDE, которые позволяют значительно автоматизировать процесс получения готового продукта. Сейчас наличием IDE для языка никого не удивишь, но С/С++ программисты долго жили без этих благ цивилизации и хочется только снять шляпу перед их мужеством и упорством.
В результате программисту не нужно заморачиваться теми проблемами, которые были описаны выше, а можно сосредоточится на написании кода, что выражается в более высокой продуктивности.
В качестве плюса заголовочных файлов иногда приводят факт, что они могут служить кратким описанием того, что собой являет каждый файл. Быть минималистичной документацией и описанием интерфейса класса.
Тем не менее, в других языках есть возможность по запросу вообще автоматически генерировать аналоги заголовочных файлов без заботы о синхронности между декларациями и реализациями функций. Так что я бы и здесь усомнился в большой необходимости существования заголовочных файлов.
На мой взгляд, мы только что рассмотрели хороший пример того, как неудачное проектное решение, сделанное в самом начале и которое пришлось поддерживать до самого конца, отражается на удобстве и безопасности использования проекта в будущем. В данном случае проектом я называю сам язык С++ как что-то, что продолжает развиваться и использоваться множеством программистов по всему миру.
Оставим обсуждение, на сколько это канонично, теоретикам, а на сколько практично — гуру. Лично мне не нравится, что указание типа наследования не сделали обязательным и в то же время по умолчанию разным для struct и class — public и private соответственно.
К этому можно привыкнуть, когда пишешь только на С++. Но когда ты здесь набегами, то даже простой на первый взгляд код может озадачить как тебя, так и компилятор.
class A
{
};
class AA: A
{
};
int main()
{
A* bp = new AA;//Error: E0269 conversion to inaccessible base class "A" is not allowed
return 0;
}
Лечится просто, наследование следовало описать так: class AA: public A, но судя по stackoverflow.com/...ritance-inaccessible-base, очевидным такое сообщение компилятора оказывается не для всех.
Требование явно указывать модификатор доступа повысило бы читаемость кода и как минимум помогло бы новичкам.
private:
//все что до следующего модификатора приватно
float _someValue;
bool _someFlag;
string _someName;
public:
//все что до следующего модификатора публично
float GetSomeValue() { return _someValue; }
bool GetSomeFlag() { return _someFlag; }
string GetSomeName() { return _someName; }
protected:
string SetSomeName(string name) { _someName = name; }
Когда класс маленький, то кажется, что так удобнее. Но когда методов и переменных так много, что они занимают как минимум несколько страниц, то без строгих правил в именовании уже не получится, глянув на заголовок метода, сказать, какой у него модификатор. При смене модификатора доступа на другой придется делать лишние движения — искать границы соответствующего блока, подходящее место для вставки, переименовывать метод.
Кроме того, в C# мне нравится располагать рядом приватные данные и публичные методы доступа к этим данным, чему блочный подход никак не способствует:
private float _someValue;
public float SomeValue { get { return _someValue; } }
private bool _someFlag;
public bool SomeFlag { get { return _someFlag; } }
Я уже молчу о синтаксическом сахаре более высокого порядка .
public string SomeName { get; private set; }
Модификатор доступа как часть заголовка каждого метода и поля класса дает большую гибкость в организации кода и повышает его читаемость. Думаю, поэтому в современных языках такой подход — распространенная практика.
До стандарта С++11 у разработчиков не было даже возможности указать на то, что метод переопределяется. Эта задача полностью лежала на компиляторе. Сейчас в стандарт добавлено ключевое слово override, которое можно встретить и в других языках, но в C++ оно, в угоду обратной совместимости, полагаю, не есть обязательным.
Есть несколько случаев, когда явное использование этого override окупается:
Если же этого не делать и по какой-либо причине деструктор базового класса окажется невиртуальным, то работа с классами-наследниками может приводить к неправильному освобождению памяти после уничтожения объектов. Самый частый способ получить такую ситуацию — сохранить объект в коллекции указателей на базовый класс. Но продемонстрирую это на еще более простом примере:
//A.h
#pragma once
#include<string>
using std::string;
class A
{
protected:
string _name;
public:
A() : _name("class A. ") {}
~A();
};
//A.cpp
#include"A.h"
#include<iostream>
using std::cout;
using std::endl;
A::~A()
{
cout << _name << "~A()" << endl;
}
//AA.h
#pragma once
#include "A.h"
class AA: public A
{
public:
AA() { _name = "class AA. "; }
~AA();
};
//AA.cpp
#include "AA.h"
#include <iostream>
using std::cout;
using std::endl;
AA::~AA()
{
cout << _name << "~AA()" << endl;
}
//main.cpp
#include<iostream>
#include "AA.h"
using std::cout;
using std::endl;
int main()
{
{
A aa = AA();
}
A* ap = new AA;
cout << "\nTest destroy ap" << endl;
delete ap;
return 0;
}
Результатом работы такого небольшого приложения будет вывод:
class AA. ~AA()
class AA. ~A()
Test destroy ap
class AA. ~A()
Как видим, если объект хранится по значению, то его уничтожение происходит правильно, даже если типом переменной будет базовый класс. Если же объект хранится через указатель на базовый класс, то удалена будет только та часть объекта, которая представляет собой базовый класс. И главное — без специальных настроек проекта или специальных синтаксических анализаторов, о наличии которых слышали далеко не все, кто пишет на С++, эту ситуацию видно только на этапе выполнения программы. В больших проектах это может оказаться нетривиальной задачей.
Понимаю, что проблема обратной совместимости не дает возможности сделать использование override обязательным для всех виртуальных методов, но посильной задачей для компилятора было бы отлавливать все ситуации наследование классов от базовых с невиртуальными деструкторами и выводить сообщение об ошибке. Тем не менее, Visual Studio 2017 даже предупреждений не выдает при компиляции выше приведенного примера ☹. И пока требование на такое поведение компилятора не включат в стандарт, С++ разработчики будут продолжать обжигаться об невиртуальные деструкторы.
Для меня неприятным открытием оказалось реализованное в С++ поведение, что если в базовом классе есть какой-либо метод, то добавление в классе-наследнике любого метода с таким же именем приводит к тому, что все методы с таким именем в базовом классе автоматически становятся недоступными объектам класса-наследника. И ладно бы это касалось исключительно перегрузки методов, это поведение затрагивает и переопределение.
Предположим, есть такой базовый класс B:
//B.h
#pragma once
#include<string>
using std::string;
class B
{
protected:
string _name;
public:
B() : _name("class B. ") {}
virtual void F();
virtual void F(int v);
virtual void F(bool v);
virtual ~B(){}
};
//B.cpp
#include"B.h"
#include<iostream>
using std::cout;
using std::endl;
void B::F()
{
cout << _name << "B.F()" << endl;
}
void B::F(int v)
{
cout << _name << "B.F(int " << v << ")" << endl;
}
void B::F(bool v)
{
cout << _name << "B.F(bool " << v << ")" << endl;
}
Со временем нам понадобился класс-наследник:
//BB.h
#pragma once
#include "B.h"
class BB: public B
{
public:
BB() { _name = "class BB. "; }
~BB() {}
};
Любой, кто уже имел опыт работы с современными ООП языками, не будет удивлен, что у объектов класса BB есть доступ ко всем методам, определенным только в классе B. И им не обязательно быть виртуальными:
//main.cpp
#include<iostream>
#include "BB.h"
using std::cout;
using std::endl;
int main()
{
BB bb;
bb.F(100000);//test 1
bb.F();//test 2
system("pause");
return 0;
}
Но стоит только в классе BB переопределить или перегрузить любой метод F():
//BB.h
#pragma once
#include "B.h"
#include <string>
#include <iostream>
using std::cout;
using std::endl;
class BB: public B
{
public:
BB() { _name = "class BB. "; }
void F() override {} //1
void F(string s) {} //2
void F(short a) { cout << "BB:F(short " << a << ")" << endl; } //3
~BB() {}
};
И неискушенных ждет сюрприз — тот же код использование класса уже не скомпилируется или будет работать некорректно.
Предлагаю провести эксперименты:
На мой взгляд, такой способ перегрузки функций очень странный. Получается, без дополнительной языковой конструкции он противоречит одному из базовых принципов ООП, что класс-наследник расширяет возможности базового класса. Не видел такого поведения ни в одном из знакомых мне языков.
struct A
{
A() { }
A(float) { }
A(int, int) { }
};
Если есть такое описание класса A, то можно создавать объекты этого класса достаточно непривычным образом:
int main()
{
A a1 = 11;
A a2({});
A a3{0, 1};
A a4 = {5, 10};
A a5 = (A)20;
}
Каждый из приведенных выше вариантов на самом деле вызывает тот или иной конструктор класса A, и это далеко не очевидно из записи. Более того, неявное преобразование значений одних типов в другие могут происходить в других ситуациях, например, при передаче параметров в метод.
void f(A value)
{
}
int main()
{
f({});
f(10);
}
Оба вызова функции f неявно вызовут соответствующий конструктор класса A. Когда вызов находится рядом с определением функции, это еще куда не шло, но обычно они далеко друг от друга и читаемость такого кода стремиться к нулевой. А в методах, где параметров несколько, а у самого метода есть несколько перегрузок, легко сделать ошибку и даже не увидеть этого.
Наверное, поэтому маститые авторы рекомендуют всегда объявлять конструкторы как explicit. Очень странно, что в стандарте этот вопрос так и не был исправлен.
И это еще не худший сценарий — так вы сразу увидите место, где произошла ошибка. В противном случае логические ошибки в отладке искать куда сложнее и дольше. Профи знают о специальных настройках компиляции и синтаксических анализаторах, которые позволят найти подобные проблемы более эффективным способом. Но я, как самоучка, услышал о таком способе только сегодня от рецензента статьи. До этого даже не подозревал, что он существует.
С полями классов ситуация еще хуже. Они могут быть автоматически инициализированными дефолтными значениями, а могут и не быть — зависит от ситуации, в какой создается объект. Правила инициализации достаточно сложные, чтобы их записать в короткой статье. Поэтому знатоки рекомендуют всегда явно инициализировать все переменные.
Но в случае классов авторы языка снова проявили оригинальность — неэффективно присваивать значение полям в теле конструктора. Рекомендуется использовать списки инициализации членов класса до тела конструктора, иначе они будут инициализированы значениями за вас. И в конструкторе вы уже переприсвоите им новые значения. И ладно бы это касалось только вызова конструкторов базовых классов, что в других языках тоже организовано через специальный синтаксис, — язык провоцирует использовать списки инициализации для всех полей.
struct A
{
public:
int Y;
int X;
int Z; //забытые переменные остаются на вашей совести
A(int value) :
X(value + 1),
Y(2*X) //в списках инициализации опасно полагаться на значения других полей
{
//классический для других языков способ инициализации в С++ считается не эффективным
//X = value + 1;
//Y = 2*X;
//Z = 0;
}
};
К сожалению, никто не подскажет, если после вызова конструктора у вас все же останутся неинициализированные поля.
Также обратите внимание, что инициализация полей в списке будет происходить не в том порядке, что вы запишете, а в порядке следования полей при их объявлении в теле класса! Поэтому либо никогда не используйте значение полей как базовое для других полей в списке инициализации, либо никогда не меняйте порядок их объявления в теле класса!
На самом деле тема инициализации значений С++ переменных более обширная. Я только обозначил ее контуры. И мне не понятно, зачем были придуманы такие сложности, которых в других языках нет.
В современных строго типизированных языках компилятор следит за тем, чтобы код не обращался к значениям локальных переменных, которым до этого не было присвоено значение. То есть такой код просто не скомпилируется и легко позволит найти подобные ошибки еще до запуска программы.
Кроме того, компиляторы таких языков позаботятся, чтобы поля всех классов были проинициализированы дефолтными значениями, либо сообщат, что вы забыли каким-то полям присвоить значение.
Даже стандарт разные компиляторы поддерживают избирательно en.cppreference.com/w/cpp/compiler_support.
Как можно увидеть по ссылке выше, есть несколько версий стандарта языка С++:
На личном опыте неоднократно сталкивался с тем, что достаточно простой код, который запускается и корректно работает на MSVC, даже не компилируется при использовании gcc и наоборот. Либо с ситуациями, когда код, скомпилированный различными компиляторами, дает разные результаты.
Если же усвоить простое правило, что не важно, сколько строк ошибок вывел компилятор, искать проблему нужно в самой первой, то привыкнуть можно. Хотя сообщения об ошибках в template-классах иногда ошарашивают многословностью.
К ошибкам линковщика привыкнуть будет сложнее. Обычно они не показывают место, где возникла проблема, а лишь говорят о том, что ваш код в нынешнем виде не может быть собран в целостное приложение или библиотеку. Даже если каждая его часть скомпилировалась по отдельности. Здесь первое время могут помочь только гугл и более опытный коллега. Но со временем и к этим ошибкам привыкаешь.
До этого компилятору особо нечего проверять. Предполагается, что в момент инстанцирования объекта он получит всю необходимую информацию и тогда проверит, допустим ли такой тип для этого класса. Если нет, то такой код не скомпилируется.
Таким образом, чтобы убедиться, что шаблонный класс работает для всех случаев, для которых его проектировали, вам придется создать тестовый код. В этом коде нужно создать объекты для всех категорий типов, которые вас интересуют.
Но просто создать объекты недостаточно, поскольку в этом случае работает правило ленивой компиляции — компилятор попробует скомпилировать только те методы, к которым вы явно обратитесь! И если класс большой и методов много, то без полноценных тестов просто понять, что в коде нет элементарных ошибок компиляции, не представляется возможным.
К счастью, в Visual Studio, начиная с версии 2017 15.8, появился Template Bar, который позволяет указать конкретный тип для шаблона. И после компилятор проверит, скомпилируется ли этот класс с этим типом, причем с учетом всех методов, которые есть в классе.
Это не решает всех проблем, но значительно облегчает жизнь. Не представляю, как раньше жили программисты без этого инструмента.
Как обычно, описанные проблемы с шаблонными классами неведомы разработчикам на других языках. Да, по результирующей мощности шаблонов мало какой язык может тягаться с С++ (D может ). Но удобство их использования куда важнее, преимущественно за счет возможности накладывать ограничения на шаблонные параметры. Это позволяет компилятору и IDE определять, что делает этот тип, и показывать ошибки сразу после того, как они были допущены, даже без необходимости что-то компилировать.
Кстати, стандарт С++20 предусматривает появление концептов, которые призваны закрыть эту брешь в языке. Сейчас эта возможность не поддерживается полностью, но надеюсь в скорости жизнь С++ разработчиков наладится хоть в этом вопросе.
Разберем простой пример. У нас есть такой вызов функции:
f(shared_ptr<SomeObj>(new SomeObj), f2());
Опытные разработчики скажут, что здесь лучше код переписать так:
auto p = shared_ptr<SomeObj>(new SomeObj);
f(p, f2());
Еще более опытные скажут, что здесь напрашивается использование std::make_shared, доступное с C++11, как более безопасное и эффективное.
auto p = std::make_shared<SomeObj>();
f(p, f2());
Но далеко не все скажут, чем именно опасен первый вариант.
А опасен он потенциальной утечкой памяти.
Причина этой потенциальности в том, что в первом варианте, кроме вызова функции f(), компилятор видит еще три действия:
На мой взгляд, в наше время, когда размеры проектов становятся только больше, безопасность написанного кода и легкость его написания куда важнее, чем призрачный выигрыш нескольких тактов процессора. Потому я приветствую подходы современных языков, где не допускаются вольности в интерпретации одного и того же кода, которые приводят к неожиданному поведению.
Реализация класса vector<bool> отличается от реализации для всех других типов тем, что здесь флаги хранятся в упакованном виде. Это позволяет в одном байте хранить до 8 флагов. И приводит к тому, что operator[] возвращает T& для всех типов аргумента, кроме bool, для которого возвращается тип vector<bool>::reference.
Если предположить, что у вас есть функция vector GenerateRandomFlags(), то будет абсолютно безопасно писать так:
bool flag = GenerateRandomFlags()[0];
cout << flag;
Поскольку vector::reference умеет неявно преобразовываться в тип bool. Но все становится печально, если положиться на оператор auto:
auto flag = GenerateRandomFlags()[0];
cout << flag;
Программа упадет при попытке обратится к значению переменной flag.
В одной из своих книг Мейерс посвящает две страницы объяснению рекомендации не использовать auto для прокси-классов. И еще две страницы рассказывает, как понять, что есть прокси-классом. Я же всего лишь рекомендую: каждый раз, когда вам придется для некоего типа делать специализацию шаблона, требующую изменения интерфейса — пожалейте себя и других пользователей этого класса, дайте ему другое имя!
Ну а если вам понадобиться коллекция флагов в классическом виде, то придется выкручиваться, используя vector<char>.
Народ, вы вызываете у меня глубокое уважение! Я прекрасно понимаю, сколько времени и сил вам понадобилось, чтобы достичь текущего уровня. Ибо я тоже потратил их не мало, а до вершины еще далеко. И все мои претензии к языку — это больше попытка предупредить идущих следом о потенциальных опасностях, желание поделиться с ними опытом.
В данный момент мне чрезмерно комфортно быть C# разработчиком под Unity и хочется новых вызовов в лице C++ с Unreal Engine. Поскольку хороших альтернатив на других высокопроизводительных языках я не знаю, буду признателен тем, кто поделиться со мной и другими читателями ссылками на материалы, которые научат правильному использованию современного C++, особенно в контексте Unreal. А также ускорят попадание в клуб избранных, куда добираются единицы. Заранее спасибо.
Буду рад, если мои мысли окажутся полезными для целевой аудитории. В случае позитивного отклика обещаю еще поделиться личным опытом решения достаточно простой задачи, которую на одних онлайн-курсах за 4 года пыталось решить более 11 000 человек, а осилили только 500. Код ее решения я написал быстро, но вот на борьбу с компилятором ушло намного больше времени.
За это время мне пришлось писать на множестве различных языков:
- с 8 по 11 класс самостоятельно изучал BASIC и Turbo Pascal в компьютерном классе школы;
- писал игры для калькулятора МК-61 дома (в средине 90-х компьютер был роскошью);
- Delphi на первой работе;
- Lua и немного C++/CLI на второй, с которой я вошел в GameDev 14 лет назад;
- Python и C++ под Bigworld на третьей;
- C# под Unity на текущей;
- в свободное время делаю свои проекты;
- та же некоторое время посвятил изучению Rust и D в попытке найти альтернативу C++.
В очередной раз с C++ работал пару месяцев назад, когда взялся разбираться с Unreal Engine. И поразился, сколько еще неизведанного осталось для меня в этом языке. Сколько еще подводных камней и возможностей выстрелить себе в ногу я подзабыл или не нашел при прошлых опытах использования.
Эта статья — попытка осмыслить, почему у C++ такой высокий порог вхождения, чем он уступает другим языкам. А также почему я считаю его плохим языком для программистов-новичков.
Целевая аудитория:
- те, кто считает, что начинать нужно со сложного и максимально эффективного;
- те, кто много писал на C#|Java и подобных и хочет покорить новые вершины;
- те, кто имеет некоторый опыт C++, но, как и я, понимает, что нет предела совершенству;
- практикующие системные программисты на языках типа Rust и D, которые смогут рассказать, почему выбрали их альтернативой C++;
- гуру C++, которые в комментариях просветят неопытных, чем и на сколько оправданы те или иные подходы языка, затронутые в статье.
Очень надеюсь, что подобранная мною информация окажется полезной и вызовет только конструктивное обсуждение.
Компиляция и линковка
Первое, с чем приходится столкнуться, — архаичное правило, что «единицей компиляции является файл», которое перекочевало в С++ из языка С. Это означает, что если файл ссылается на что-то из других файлов, то нужно каким-то образом сообщить компилятору тот минимум информации, что позволит ему выполнить работу. Такой информацией являются преимущественно объявления используемых этим файлом функций — копии заголовка функций без тела.Чтобы компилятор получил эту информацию, сначала отрабатывает препроцессор. Он не менее архаичным способом переносит все содержимое каждого включаемого файла в тот, который компилируется. Если в каком-то из этих файлов были включения, они тоже добавляются.
Чтобы не включать избыточую для компилятора информацию (ему код включаемых функций не нужен), договорились все заголовки функций выносить в отдельные файлы, которые и назвали заголовочными. Таким образом возникло разделение, что заголовочные файлы имеют расширение .h (иногда пишут .hpp, чтобы явно указать, что это написано на С++), а файлы с кодом — в файлах с расширением .cpp.
Правда, копирование даже заголовков в другие файлы при каждой их компиляции в большом проекте приводит к тому, что подготовка файлов к компиляции и сам процесс компиляции занимают достаточно много времени. Активное использование шаблонных классов, которые при специализации создают как бы полную копию класса для каждого типа, еще больше усугубляет ситуация с временем компиляции. В результате без специальных ухищрений типа Precompiled Headers, IncrediBuild время компиляции будет на порядок дольше схожих по размеру проектов на других языках. Но даже с ними время компиляции будет значительно уступать.
Приведу пример.
Сейчас я работаю над проектом на C# под Unity, который состоит из 4300 файлов с кодом. Это 25 мегабайт исходников! Время полной компиляции проекта на моем компьютере занимает 10 секунд. Берем пустой проект на Unreal, добавляем единственный объект с С++ классом. Вносим туда малейшее изменение — время компиляций и линковки до запуска, минимум 7 секунд на том же i7-8700.
Но скорость компиляции далеко не единственная «особенность», с которой сталкиваются разработчики на С++ при использовании заголовочных файлов. Существует целый ряд маленьких и не очень проблем-прикольчиков, которые каждый день усложняют им и так непростую жизнь.
1. Так как заголовочные файлы могут включать другие заголовочные файлы, то возможна ситуация, что одно и то же объявление функции будет подставлено более одного раза.
Для компилятора это может быть проблемой, и для ее решения сейчас используют директиву препроцессора #pragma once. Выглядит это так:
// unit.h
#pragma once
void f1(); // пример описания заголовка (сигнатуры) функции f1()
bool f2(int x); //пример описания заголовка (сигнатуры) функции f2()
Но вы можете увидеть и такой вариант решения проблемы, которым пользовались до появления поддержки #pragma once:
// unit.h
#ifndef __UNIT_H__
#define __UNIT_H__
void f1(); // пример описания заголовка (сигнатуры) функции f1()
bool f2(int x); //пример описания заголовка (сигнатуры) функции f2()
#endif
Здесь для каждого файла программист придумывал уникальное имя константы препроцессора. Тогда код между #define и #endif включался только при первой попытке подключить этот заголовочный файл к текущему компилируемому файлу.
Матерые С/С++ программисты даже не посчитают это проблемой, поскольку этот код пишется раз и делается на автомате. Но новичкам приходится запоминать лишнее понятие, которых в этом языке еще ой как много .
2. Второй прикольчик связан с правилом компилятора «все, что выглядит как декларация функции, будет считаться декларацией функции».
С++ представляет много способов создания объектов, и в случае использования круглых скобок только конструктор без параметров не предусматривает использование круглых скобок в угоду обсуждаемому правилу!
ClassA obj1(10); //создание объекта путем вызова конструктора с одним параметром
ClassB obj2(true, 2); //создание объекта путем вызова конструктора с двумя параметрами
ClassC obj3(); //объявление функции без параметров, которая возвращает значение типа ClassC
ClassC obj4; //создание объекта, путем вызова конструктора без параметров
Но проблемы с компилятором легко могут возникнуть и при использовании конструктора с большим числом параметров. Для приведения значения к другому типу С++ представляет два похожих варианта (T)value и T(value). Правда, это работает не всегда. Вопрос к новичкам: как думаете, где вызов конструктора, а где декларация заголовка функции?
int k = 5;
ClassA obj1((float)k);
ClassA obj2(float(k));
В данном случае компилятор посчитает, что obj1 — создание объекта путем вызова конструктора с одним параметром, а obj2 — это объявление функции, которая в качестве параметра получает значение типа float. Как видите, для того чтобы вас поняли правильно, нужно знать немало тонкостей.
3. Добавление новых функций, а также изменение сигнатуры существующих.
Заголовочные файлы приходится создавать почти для каждого класса, и чисто технически сделать это не сложно. Тем более, что в IDE этот процесс автоматизирован.
Но вот заметное неудобство — каждый раз, когда вам понадобиться в класс добавить функцию, придется ее заголовок продублировать в заголовочный файл. Если добавить функцию только в .cpp файл, то о проблеме подскажет компилятор. А вот если добавить заголовок функции в .h файл, но забыть в .cpp — об ошибке вы узнаете от линковщика. Информативностью его сообщения не отличаются.
Простой пример:
// file main.cpp
#include"A.h"
int main()
{
A a;
a.f();//если закомментировать единственное обращение к «забытому» методу, ошибок не будет
return 0;
}
// file A.h
#pragma once
class A
{
public:
A();
~A();
int f();//эту функцию «забыли» добавить в .cpp файл
};
// file A.cpp
#include "A.h"
A::A()
{
}
A::~A()
{
}
Получим такое сообщение об ошибке
main.obj : error LNK2019: unresolved external symbol "public: int __thiscall A::f(void)" (?f@A@@QAEHXZ) referenced in function _main
Со временем к ошибкам линковщика можно привыкнуть, но новичков такие сообщения часто повергают в фрустрацию. Клик по ошибке в IDE не подсвечивает строчку кода с ней. Следует быть внимательным и при изменении сигнатуры метода обязательно вносить изменения в оба файла. Иначе будете постоянно получать сообщения об ошибках, а у компилятора не всегда получается быть очевидным. Эта необходимость держать два файла синхронизированными заставляет постоянно копипастить куски кода, что в мире программирования, как правило, порицается.
4. Преобразование обычного класса в шаблонный.
Достаточно часто в моей практике возникала ситуация, когда сначала создавался обычный класс, а позднее появлялась необходимость преобразовать его в шаблонный, так как находилось еще несколько типов, которые имели тоже самое поведение.
В C# такая трансформация проводится достаточно просто. Например, такой примитивный класс:
Когда появится необходимость для контейнера использовать любой другой тип, достаточно:
Преобразование выразилось в замену стартового типа шаблонным и приписыванием <T> после имени класса.
Теперь посмотрим, что нужно сделать в случае C++. Было:
Для преобразования недостаточно только заменить int типом T. Придется еще много чего дописать (см. выделение цветом), прежде чем компилятор скомпилирует этот класс:
Причина в том, что для шаблонных классов сделано исключение — объявление функций должно быть в том же файле, что и их реализация! Так что для успешного окончания преобразования достаточно содержимое .cpp файла перенести в .h файл.
Но при желании можно все свести к еще более компактному виду. Ничего не напоминает?
Могут ведь, если хотят! Правда, громоздкий вариант выноса определения функций за пределы шаблонного класса тоже часто встречается. Уж не знаю, какие преимущества он дает.
5. Сборка проекта — линковка.
Если вы собираете проект через командную строку, как показывают во многих книгах или на онлайн-курсах, то без ухищрений процесс сборки сложно назвать увлекательным.
Вот как выглядит очередная итерация после внесения изменений в проект:
- перекомпилируем те файлы, которые изменились;
- запускаем специальную программу-линковщик. Ей на вход нужно передать путь к абсолютно всем объектным файлам, из которых состоит проект;
- переходим к следующему шагу, если линковщик не выявит недостачи кода функций, которые были обещаны компилятору во всех заголовочных файлах. Если же линковщик будет недоволен — ждите «информативных» сообщений об ошибках;
- результатом работы этой программы будет либо исполняемый файл, либо библиотека — в зависимости от ключей запуска.
К счастью, есть еще IDE, которые позволяют значительно автоматизировать процесс получения готового продукта. Сейчас наличием IDE для языка никого не удивишь, но С/С++ программисты долго жили без этих благ цивилизации и хочется только снять шляпу перед их мужеством и упорством.
А как с компиляцией в других языках
Современные языки пошли другим путем, так как появились позже, и их разработчики могли оценить сложности подхода С++ в отношении компиляции проектов. Обычно единицей компиляции служит условный модуль. Компилятор заранее знает, из каких файлов состоит то, что он компилирует, и сам может построить все связи. При чем этот подход исповедуют не только прикладные языки типа C# или Java, но и вполне системные Rust и D.В результате программисту не нужно заморачиваться теми проблемами, которые были описаны выше, а можно сосредоточится на написании кода, что выражается в более высокой продуктивности.
В качестве плюса заголовочных файлов иногда приводят факт, что они могут служить кратким описанием того, что собой являет каждый файл. Быть минималистичной документацией и описанием интерфейса класса.
Тем не менее, в других языках есть возможность по запросу вообще автоматически генерировать аналоги заголовочных файлов без заботы о синхронности между декларациями и реализациями функций. Так что я бы и здесь усомнился в большой необходимости существования заголовочных файлов.
На мой взгляд, мы только что рассмотрели хороший пример того, как неудачное проектное решение, сделанное в самом начале и которое пришлось поддерживать до самого конца, отражается на удобстве и безопасности использования проекта в будущем. В данном случае проектом я называю сам язык С++ как что-то, что продолжает развиваться и использоваться множеством программистов по всему миру.
Современные подходы в языках, которых не встретишь в С++
Наследование
Основные ООП языки исповедуют принцип: наследование — это расширение функциональности базового класса. То есть класс-наследник — это то же самое, что базовый класс, плюс дополнительная функциональность и|или данные. В С++ решили дать возможность управлять наследованием. Помимо привычного публичного наследование есть еще приватное и защищенное.Оставим обсуждение, на сколько это канонично, теоретикам, а на сколько практично — гуру. Лично мне не нравится, что указание типа наследования не сделали обязательным и в то же время по умолчанию разным для struct и class — public и private соответственно.
К этому можно привыкнуть, когда пишешь только на С++. Но когда ты здесь набегами, то даже простой на первый взгляд код может озадачить как тебя, так и компилятор.
class A
{
};
class AA: A
{
};
int main()
{
A* bp = new AA;//Error: E0269 conversion to inaccessible base class "A" is not allowed
return 0;
}
Лечится просто, наследование следовало описать так: class AA: public A, но судя по stackoverflow.com/...ritance-inaccessible-base, очевидным такое сообщение компилятора оказывается не для всех.
Требование явно указывать модификатор доступа повысило бы читаемость кода и как минимум помогло бы новичкам.
Модификаторы доступа
В С++ используется блочный подход:private:
//все что до следующего модификатора приватно
float _someValue;
bool _someFlag;
string _someName;
public:
//все что до следующего модификатора публично
float GetSomeValue() { return _someValue; }
bool GetSomeFlag() { return _someFlag; }
string GetSomeName() { return _someName; }
protected:
string SetSomeName(string name) { _someName = name; }
Когда класс маленький, то кажется, что так удобнее. Но когда методов и переменных так много, что они занимают как минимум несколько страниц, то без строгих правил в именовании уже не получится, глянув на заголовок метода, сказать, какой у него модификатор. При смене модификатора доступа на другой придется делать лишние движения — искать границы соответствующего блока, подходящее место для вставки, переименовывать метод.
Кроме того, в C# мне нравится располагать рядом приватные данные и публичные методы доступа к этим данным, чему блочный подход никак не способствует:
private float _someValue;
public float SomeValue { get { return _someValue; } }
private bool _someFlag;
public bool SomeFlag { get { return _someFlag; } }
Я уже молчу о синтаксическом сахаре более высокого порядка .
public string SomeName { get; private set; }
Модификатор доступа как часть заголовка каждого метода и поля класса дает большую гибкость в организации кода и повышает его читаемость. Думаю, поэтому в современных языках такой подход — распространенная практика.
Переопределение методов
Переопределение — это создание метода с той же сигнатурой. Мне нравится современный подход, когда переопределяемый метод явно объявляется таким.До стандарта С++11 у разработчиков не было даже возможности указать на то, что метод переопределяется. Эта задача полностью лежала на компиляторе. Сейчас в стандарт добавлено ключевое слово override, которое можно встретить и в других языках, но в C++ оно, в угоду обратной совместимости, полагаю, не есть обязательным.
Есть несколько случаев, когда явное использование этого override окупается:
- Компилятор перепроверит, а вы точно переопределяете то, что хотели:
- Когда вы только переопределяете метод в классе-наследнике, то можете ошибиться в сигнатуре (названии, количестве или типе параметров).
- Когда в какой-то момент вы решите удалить виртуальный метод из базового класса, то компилятор укажет на все переопределенные методы в классах-наследниках. И пока вы не решите эту проблему — проект не скомпилируется.
- В языках, где переопределение обязано быть явным, компилятор предупредит и о ситуации, когда в классе-наследнике был определен метод, а через некоторое время кто-то пробует в базовом классе добавить метод с такой же сигнатурой.
- Виртуальные деструкторы. Эта проблема оказалась настолько массовой, что ее упоминают чуть ли не в каждой книге. Но это не мешает новичкам постоянно наступать на эти грабли. Потому упомяну о ней и я немного.
Если же этого не делать и по какой-либо причине деструктор базового класса окажется невиртуальным, то работа с классами-наследниками может приводить к неправильному освобождению памяти после уничтожения объектов. Самый частый способ получить такую ситуацию — сохранить объект в коллекции указателей на базовый класс. Но продемонстрирую это на еще более простом примере:
//A.h
#pragma once
#include<string>
using std::string;
class A
{
protected:
string _name;
public:
A() : _name("class A. ") {}
~A();
};
//A.cpp
#include"A.h"
#include<iostream>
using std::cout;
using std::endl;
A::~A()
{
cout << _name << "~A()" << endl;
}
//AA.h
#pragma once
#include "A.h"
class AA: public A
{
public:
AA() { _name = "class AA. "; }
~AA();
};
//AA.cpp
#include "AA.h"
#include <iostream>
using std::cout;
using std::endl;
AA::~AA()
{
cout << _name << "~AA()" << endl;
}
//main.cpp
#include<iostream>
#include "AA.h"
using std::cout;
using std::endl;
int main()
{
{
A aa = AA();
}
A* ap = new AA;
cout << "\nTest destroy ap" << endl;
delete ap;
return 0;
}
Результатом работы такого небольшого приложения будет вывод:
class AA. ~AA()
class AA. ~A()
Test destroy ap
class AA. ~A()
Как видим, если объект хранится по значению, то его уничтожение происходит правильно, даже если типом переменной будет базовый класс. Если же объект хранится через указатель на базовый класс, то удалена будет только та часть объекта, которая представляет собой базовый класс. И главное — без специальных настроек проекта или специальных синтаксических анализаторов, о наличии которых слышали далеко не все, кто пишет на С++, эту ситуацию видно только на этапе выполнения программы. В больших проектах это может оказаться нетривиальной задачей.
Понимаю, что проблема обратной совместимости не дает возможности сделать использование override обязательным для всех виртуальных методов, но посильной задачей для компилятора было бы отлавливать все ситуации наследование классов от базовых с невиртуальными деструкторами и выводить сообщение об ошибке. Тем не менее, Visual Studio 2017 даже предупреждений не выдает при компиляции выше приведенного примера ☹. И пока требование на такое поведение компилятора не включат в стандарт, С++ разработчики будут продолжать обжигаться об невиртуальные деструкторы.
Перегрузка методов
В отличие от переопределения, перегрузка — это создание метода с тем же именем, но другой сигнатурой. В чистом С такой возможности не было, в С++ ее добавили, но, как обычно бывает в этом языке, нестандартно.Для меня неприятным открытием оказалось реализованное в С++ поведение, что если в базовом классе есть какой-либо метод, то добавление в классе-наследнике любого метода с таким же именем приводит к тому, что все методы с таким именем в базовом классе автоматически становятся недоступными объектам класса-наследника. И ладно бы это касалось исключительно перегрузки методов, это поведение затрагивает и переопределение.
Предположим, есть такой базовый класс B:
//B.h
#pragma once
#include<string>
using std::string;
class B
{
protected:
string _name;
public:
B() : _name("class B. ") {}
virtual void F();
virtual void F(int v);
virtual void F(bool v);
virtual ~B(){}
};
//B.cpp
#include"B.h"
#include<iostream>
using std::cout;
using std::endl;
void B::F()
{
cout << _name << "B.F()" << endl;
}
void B::F(int v)
{
cout << _name << "B.F(int " << v << ")" << endl;
}
void B::F(bool v)
{
cout << _name << "B.F(bool " << v << ")" << endl;
}
Со временем нам понадобился класс-наследник:
//BB.h
#pragma once
#include "B.h"
class BB: public B
{
public:
BB() { _name = "class BB. "; }
~BB() {}
};
Любой, кто уже имел опыт работы с современными ООП языками, не будет удивлен, что у объектов класса BB есть доступ ко всем методам, определенным только в классе B. И им не обязательно быть виртуальными:
//main.cpp
#include<iostream>
#include "BB.h"
using std::cout;
using std::endl;
int main()
{
BB bb;
bb.F(100000);//test 1
bb.F();//test 2
system("pause");
return 0;
}
Но стоит только в классе BB переопределить или перегрузить любой метод F():
//BB.h
#pragma once
#include "B.h"
#include <string>
#include <iostream>
using std::cout;
using std::endl;
class BB: public B
{
public:
BB() { _name = "class BB. "; }
void F() override {} //1
void F(string s) {} //2
void F(short a) { cout << "BB:F(short " << a << ")" << endl; } //3
~BB() {}
};
И неискушенных ждет сюрприз — тот же код использование класса уже не скомпилируется или будет работать некорректно.
Предлагаю провести эксперименты:
- Оставить функцию 1 и закомментировать 2 и 3.
Не скомпилируется test1. - Оставить функцию 2 и закомментировать 1 и 3.
Не скомпилируется test1 и test2. - Оставить функцию 3 и закомментировать 1 и 2.
Не скомпилируется test2. А вот test1 скомпилируется, но на экран будет выведено не B::F(int 100000), а BB::F(short −31072).
На мой взгляд, такой способ перегрузки функций очень странный. Получается, без дополнительной языковой конструкции он противоречит одному из базовых принципов ООП, что класс-наследник расширяет возможности базового класса. Не видел такого поведения ни в одном из знакомых мне языков.
Неявный вызов конструктора
Еще один спорный, на мой взгляд, момент — возможность языка С++ задавать конструкторы как явные/неявные. И ладно бы значением по умолчанию выбрали явное поведение, нет — все конструкторы без explicit в сигнатуре позволяют неявное преобразование. До стандарта С++11 такими были только конструкторы с одним параметром, после — такими стали все, где можно применять list initialization.struct A
{
A() { }
A(float) { }
A(int, int) { }
};
Если есть такое описание класса A, то можно создавать объекты этого класса достаточно непривычным образом:
int main()
{
A a1 = 11;
A a2({});
A a3{0, 1};
A a4 = {5, 10};
A a5 = (A)20;
}
Каждый из приведенных выше вариантов на самом деле вызывает тот или иной конструктор класса A, и это далеко не очевидно из записи. Более того, неявное преобразование значений одних типов в другие могут происходить в других ситуациях, например, при передаче параметров в метод.
void f(A value)
{
}
int main()
{
f({});
f(10);
}
Оба вызова функции f неявно вызовут соответствующий конструктор класса A. Когда вызов находится рядом с определением функции, это еще куда не шло, но обычно они далеко друг от друга и читаемость такого кода стремиться к нулевой. А в методах, где параметров несколько, а у самого метода есть несколько перегрузок, легко сделать ошибку и даже не увидеть этого.
Наверное, поэтому маститые авторы рекомендуют всегда объявлять конструкторы как explicit. Очень странно, что в стандарте этот вопрос так и не был исправлен.
Инициализация переменных
В С++ из языка С перекочевала особенность не инициализировать локальные переменные значениями по умолчанию. В большинстве случаев в переменных окажутся те байты, которые были на стеке в момент их создания. Поэтому полагаться на что-то вменяемое в локальных переменных без их ручной инициализации не стоит. Более того, известный автор Скотт Мейерс пишет, что на некоторых платформах чтение неинициализированной переменной приводит к аварийной остановке программы.И это еще не худший сценарий — так вы сразу увидите место, где произошла ошибка. В противном случае логические ошибки в отладке искать куда сложнее и дольше. Профи знают о специальных настройках компиляции и синтаксических анализаторах, которые позволят найти подобные проблемы более эффективным способом. Но я, как самоучка, услышал о таком способе только сегодня от рецензента статьи. До этого даже не подозревал, что он существует.
С полями классов ситуация еще хуже. Они могут быть автоматически инициализированными дефолтными значениями, а могут и не быть — зависит от ситуации, в какой создается объект. Правила инициализации достаточно сложные, чтобы их записать в короткой статье. Поэтому знатоки рекомендуют всегда явно инициализировать все переменные.
Но в случае классов авторы языка снова проявили оригинальность — неэффективно присваивать значение полям в теле конструктора. Рекомендуется использовать списки инициализации членов класса до тела конструктора, иначе они будут инициализированы значениями за вас. И в конструкторе вы уже переприсвоите им новые значения. И ладно бы это касалось только вызова конструкторов базовых классов, что в других языках тоже организовано через специальный синтаксис, — язык провоцирует использовать списки инициализации для всех полей.
struct A
{
public:
int Y;
int X;
int Z; //забытые переменные остаются на вашей совести
A(int value) :
X(value + 1),
Y(2*X) //в списках инициализации опасно полагаться на значения других полей
{
//классический для других языков способ инициализации в С++ считается не эффективным
//X = value + 1;
//Y = 2*X;
//Z = 0;
}
};
К сожалению, никто не подскажет, если после вызова конструктора у вас все же останутся неинициализированные поля.
Также обратите внимание, что инициализация полей в списке будет происходить не в том порядке, что вы запишете, а в порядке следования полей при их объявлении в теле класса! Поэтому либо никогда не используйте значение полей как базовое для других полей в списке инициализации, либо никогда не меняйте порядок их объявления в теле класса!
На самом деле тема инициализации значений С++ переменных более обширная. Я только обозначил ее контуры. И мне не понятно, зачем были придуманы такие сложности, которых в других языках нет.
В современных строго типизированных языках компилятор следит за тем, чтобы код не обращался к значениям локальных переменных, которым до этого не было присвоено значение. То есть такой код просто не скомпилируется и легко позволит найти подобные ошибки еще до запуска программы.
Кроме того, компиляторы таких языков позаботятся, чтобы поля всех классов были проинициализированы дефолтными значениями, либо сообщат, что вы забыли каким-то полям присвоить значение.
Компилятор и совместимость
Существует несколько основных компиляторов от различных разработчиков. В связи с этим один и тот же код, скомпилированный различными компиляторами, может вести себя по-разному. Особенно если используются возможности языка, которые в стандарте имеют примечание «не гарантируется». Например, не гарантируется, что порядок вычисления значений параметров при вызове метода будет строгим.Даже стандарт разные компиляторы поддерживают избирательно en.cppreference.com/w/cpp/compiler_support.
Как можно увидеть по ссылке выше, есть несколько версий стандарта языка С++:
- Первые упоминания о нем относятся к 1983 году, а первая коммерческая версия появилась в 1985-м.
- В 1998 году вышла первая версия стандарта С++98.
- В 2003-м С++98 был немного доработан и появился стандарт С++03.
- В 2011-м был принят стандарт С++11, который вносил значительный объем изменений.
- В 2014 году С++11 был немного доработан и вышел С++14.
- Язык развивался, и в конце 2017-го выпустили стандарт С++17. Он добавил кое-что новое и полезное в язык, но изменения по масштабу уступают привнесенным при выходе С++11.
- В мае 2020 года ожидается утверждение стандарта С++20, который по объему изменений должен потягаться с С++11.
На личном опыте неоднократно сталкивался с тем, что достаточно простой код, который запускается и корректно работает на MSVC, даже не компилируется при использовании gcc и наоборот. Либо с ситуациями, когда код, скомпилированный различными компиляторами, дает разные результаты.
Информативность компилятора
К этому добавим низкую информативность сообщений об ошибках компилятора, даже, например, в случае пропуска точки с запятой. И становится понятно, почему порог входа в этот язык такой высокий.Если же усвоить простое правило, что не важно, сколько строк ошибок вывел компилятор, искать проблему нужно в самой первой, то привыкнуть можно. Хотя сообщения об ошибках в template-классах иногда ошарашивают многословностью.
К ошибкам линковщика привыкнуть будет сложнее. Обычно они не показывают место, где возникла проблема, а лишь говорят о том, что ваш код в нынешнем виде не может быть собран в целостное приложение или библиотеку. Даже если каждая его часть скомпилировалась по отдельности. Здесь первое время могут помочь только гугл и более опытный коллега. Но со временем и к этим ошибкам привыкаешь.
Компиляция шаблонных классов
Шаблонный класс — это болванка класса, в которую будет подставлен конкретный тип/типы в момент создания объекта этого класса.До этого компилятору особо нечего проверять. Предполагается, что в момент инстанцирования объекта он получит всю необходимую информацию и тогда проверит, допустим ли такой тип для этого класса. Если нет, то такой код не скомпилируется.
Таким образом, чтобы убедиться, что шаблонный класс работает для всех случаев, для которых его проектировали, вам придется создать тестовый код. В этом коде нужно создать объекты для всех категорий типов, которые вас интересуют.
Но просто создать объекты недостаточно, поскольку в этом случае работает правило ленивой компиляции — компилятор попробует скомпилировать только те методы, к которым вы явно обратитесь! И если класс большой и методов много, то без полноценных тестов просто понять, что в коде нет элементарных ошибок компиляции, не представляется возможным.
К счастью, в Visual Studio, начиная с версии 2017 15.8, появился Template Bar, который позволяет указать конкретный тип для шаблона. И после компилятор проверит, скомпилируется ли этот класс с этим типом, причем с учетом всех методов, которые есть в классе.
Это не решает всех проблем, но значительно облегчает жизнь. Не представляю, как раньше жили программисты без этого инструмента.
Как обычно, описанные проблемы с шаблонными классами неведомы разработчикам на других языках. Да, по результирующей мощности шаблонов мало какой язык может тягаться с С++ (D может ). Но удобство их использования куда важнее, преимущественно за счет возможности накладывать ограничения на шаблонные параметры. Это позволяет компилятору и IDE определять, что делает этот тип, и показывать ошибки сразу после того, как они были допущены, даже без необходимости что-то компилировать.
Кстати, стандарт С++20 предусматривает появление концептов, которые призваны закрыть эту брешь в языке. Сейчас эта возможность не поддерживается полностью, но надеюсь в скорости жизнь С++ разработчиков наладится хоть в этом вопросе.
Четкие правила — залог успеха
В стандарте С++ существует ряд правил, которые оставляют на откуп компилятору то, как будет скомпилирован код. Компиляторы в таких случаях обычно ориентируются на оптимальность выполнения сгенерированного кода. И это может легко приводить к поведению, которое из кода не очевидно, и логическим ошибкам.Разберем простой пример. У нас есть такой вызов функции:
f(shared_ptr<SomeObj>(new SomeObj), f2());
Опытные разработчики скажут, что здесь лучше код переписать так:
auto p = shared_ptr<SomeObj>(new SomeObj);
f(p, f2());
Еще более опытные скажут, что здесь напрашивается использование std::make_shared, доступное с C++11, как более безопасное и эффективное.
auto p = std::make_shared<SomeObj>();
f(p, f2());
Но далеко не все скажут, чем именно опасен первый вариант.
А опасен он потенциальной утечкой памяти.
Причина этой потенциальности в том, что в первом варианте, кроме вызова функции f(), компилятор видит еще три действия:
- new SomeObj — создание объекта типа SomeObj;
- передача созданного объекта в конструктор shared_ptr;
- вызов функции f2().
На мой взгляд, в наше время, когда размеры проектов становятся только больше, безопасность написанного кода и легкость его написания куда важнее, чем призрачный выигрыш нескольких тактов процессора. Потому я приветствую подходы современных языков, где не допускаются вольности в интерпретации одного и того же кода, которые приводят к неожиданному поведению.
Классы с похожим поведением, но разным интерфейсом
Напоследок хочу привести простой пример, который, думаю, известен всем C++ разработчикам, начиная от уровня Middle. Это проектная ошибка, которая до сих пор заставляет спотыкаться новичков, казалось бы, на ровном месте.Реализация класса vector<bool> отличается от реализации для всех других типов тем, что здесь флаги хранятся в упакованном виде. Это позволяет в одном байте хранить до 8 флагов. И приводит к тому, что operator[] возвращает T& для всех типов аргумента, кроме bool, для которого возвращается тип vector<bool>::reference.
Если предположить, что у вас есть функция vector GenerateRandomFlags(), то будет абсолютно безопасно писать так:
bool flag = GenerateRandomFlags()[0];
cout << flag;
Поскольку vector::reference умеет неявно преобразовываться в тип bool. Но все становится печально, если положиться на оператор auto:
auto flag = GenerateRandomFlags()[0];
cout << flag;
Программа упадет при попытке обратится к значению переменной flag.
В одной из своих книг Мейерс посвящает две страницы объяснению рекомендации не использовать auto для прокси-классов. И еще две страницы рассказывает, как понять, что есть прокси-классом. Я же всего лишь рекомендую: каждый раз, когда вам придется для некоего типа делать специализацию шаблона, требующую изменения интерфейса — пожалейте себя и других пользователей этого класса, дайте ему другое имя!
Ну а если вам понадобиться коллекция флагов в классическом виде, то придется выкручиваться, используя vector<char>.
Вывод
У опытных C++ разработчиков давно могла возникнуть мысль: «Не нравится язык — не используй! Он для суровых парней, которые привыкли к трудностям и могут в голове удержать тысячу нюансов. Накрепко выучили множество правил и всегда используют проверенные методики, что позволяют обходить проблемные места».Народ, вы вызываете у меня глубокое уважение! Я прекрасно понимаю, сколько времени и сил вам понадобилось, чтобы достичь текущего уровня. Ибо я тоже потратил их не мало, а до вершины еще далеко. И все мои претензии к языку — это больше попытка предупредить идущих следом о потенциальных опасностях, желание поделиться с ними опытом.
В данный момент мне чрезмерно комфортно быть C# разработчиком под Unity и хочется новых вызовов в лице C++ с Unreal Engine. Поскольку хороших альтернатив на других высокопроизводительных языках я не знаю, буду признателен тем, кто поделиться со мной и другими читателями ссылками на материалы, которые научат правильному использованию современного C++, особенно в контексте Unreal. А также ускорят попадание в клуб избранных, куда добираются единицы. Заранее спасибо.
Буду рад, если мои мысли окажутся полезными для целевой аудитории. В случае позитивного отклика обещаю еще поделиться личным опытом решения достаточно простой задачи, которую на одних онлайн-курсах за 4 года пыталось решить более 11 000 человек, а осилили только 500. Код ее решения я написал быстро, но вот на борьбу с компилятором ушло намного больше времени.
DOU
DOU – Найбільша спільнота розробників України. Все про IT: цікаві статті, інтервʼю, розслідування, дослідження ринку, свіжі новини та події. Спілкування на форумі з айтівцями на найгарячіші теми та технічні матеріали від експертів. Вакансії, рейтинг IT-компаній, відгуки співробітників, аналітика...
dou.ua