Недружелюбность C++ к новичкам: взгляд Unity-разработчика
Привет, меня зовут Максим. Я программист-самоучка, свою первую строчку кода написал еще в 1994 году и на текущий момент принял участие где-то в 10 игровых проектах.
За это время мне пришлось писать на множестве различных языков:
- с 8 по 11 класс самостоятельно изучал BASIC и Turbo Pascal в компьютерном классе школы;
- писал игры для калькулятора МК-61 дома (в средине
90-х компьютер был роскошью); - Delphi на первой работе;
- Lua и немного C++/CLI на второй, с которой я вошел в GameDev 14 лет назад;
- Python и C++ под Bigworld на третьей;
- C# под Unity на текущей;
- в свободное время делаю свои проекты;
- та же некоторое время посвятил изучению Rust и D в попытке найти альтернативу C++.
Как можно было заметить, с 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 включался только при первой попытке подключить этот заголовочный файл к текущему компилируемому файлу.
Матерые С/С++ программисты даже не посчитают это проблемой, поскольку этот код пишется раз и делается на автомате. Но новичкам приходится запоминать лишнее понятие, которых в этом языке еще ой как много