C++ Core Guidelines

Материал из KONANlabs
Перейти к: навигация, поиск

Вольный перевод (скорее, изложение) https://github.com/isocpp/CppCoreGuidelines/blob/master/CppCoreGuidelines.md

Содержание

I: Введение

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

P: Философия

P.1: Самокомментирующийся код

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

class myDate {
  year_t year;
  month_t month;
  day_t day;
};

Кастомные типы кажутся бессмысленными в объявлении членов класса, но в параметрах методов и функций они будут явно указывать не только тип, но и назначение передаваемых данных, устраняя необходимость в комментировании параметров и результата функции:

day_t period(myDate from, myDate to)

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

P.2: Православные плюсы

С точки зрения переносимости и реюзабельности кода, он (код) должен быть написан без использования каких-либо расширений, сторонних библиотек и т. п. Разумеется, на практике это недостижимо - всегда нужно использовать какие-то платформо-зависимые связи (аппаратный ввод-вывод, файлы, базы даных и т. п., не говоря уже о embedded systems). Хорошей практикой является разделение чистой бизнес-логики (которая пишется на ISO C++) и платформо-зависимой части, оформленной в виде интерфейса, к которому обращается бизнес-логика. В этом случае вся работа по портированию сводится к адаптации интерфейса, вместо того, чтобы перепиливать каждый класс.

Например, мы пишем класс для сложного моргания светодиодом. В классе масса методов, каждый из которых вызывается для выбранного режима моргания. На Arduino непосредственное управление светодиодом реализуется функцией digitalWrite(bool), для управления GPIO платы Raspberry Pi нужно использовать соответствующую библиотеку или вызывать утилиту командной строки, и делать эти вызовы в каждом методе управления светодиодом. Если же создать класс-интерфейс, предоставляющий методы управления GPIO по заранее оговорённой нумерации ног, портирование класса моргания (и всех остальных классов, работающих с GPIO) будет заключаться только в переписывании интерфейса.

P.3: Опять самокомментирующийся код

Оригинальное название правила - "выразить намерение". Те же сопли про читабельность и понимабельность, плюс ссылка на библиотеки gsl и sdtlib. В качестве примера - чем писать цикл while() с явным управлением переменной и условием завершения, читабельнее использовать range-based for. Философия: по возможности, писать что надо сделать, а не как надо это сделать (если "как" уже давно написано в stdlib etc.).

Применимость:

  • диапазонный for вместо обычного;
  • span<T> вместо передачи указателя и размерности;
  • loop variables in too large a scope (?);
  • naked new and delete (?).

P.4: В идеале, программа должна быть типобезопасной

Причём типобезопасной на стадии компиляции. На практике это недостижимо. Существуют следующие слабые места, приводящие к серьёзным проблемам вплоть до segmentation fault, их использование необходимо минимизировать:

  • union - обращение к одной и той же области памяти под разными типами данных, альтернатива - variant (C++17);
  • cast - приведение одного типа к другому, альтернатива - шаблоны;
  • array decay (распад массива?) и вылет за границы, альтернатива - span из GSL;
  • сужающие преобразования (напр., int16 в int8), альтернатива - narrow и narrow_cast из GSL.

P.5: Предпочитать проверку во время компиляции (compile-time) проверке во время выполнения (run-time)

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

void fill(int* p, int n);   // считать откуда-то n значений int в массив по адресу *p

int a[100];
read(a, 1000);    // segmentation fault во время выполнения

пишем

void read(span<int> r); // считать откуда-то n значений int в массив по адресу *p

int a[100];
read(a);        // предоставим компилятору посчитать размер массива

Альтернативная формулировка: не откладывать на время выполнения то, что может быть сделано во время компиляции.

P.6: То, что невозможно проверить во время компиляции, должно проверяться во время выполнения

Оставляя возможность возникновения сложноуловимых багов времени выполнения, мы на этапе проектирования закладываем глючность и ненадёжность решения. Пример - передавать во внешнюю (библиотечную) функцию указатель на массив (тот, кто пишет функцию, должен помнить сколько элементов я ему передаю, и я это должен помнить тоже). Немногим лучше передача размера массива вторым параметром, поскольку передача ошибочного размера никак не может быть детектирована. Оптимально с точки зрения контроля размерности передавать span, vector и т. п., однако это требует, чтобы внешняя библиотека была скомпилирована совместимым (читай - таким же) компилятором с той же версией stdlib.

Оптимальных решений правило не предлагает. Вообще кажется странным использовать сторонние либы с произвольными наборами данных. Оптимальное решение в такой ситуации - C-строки :)

P.7: Отлавливать ошибки времени выполнения как можно раньше

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

Примеры не очень релевантны, упоминаются предыдущие пункты, идея этого правила в том, что:

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

P.8: Не допускать утечек

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

Проще говоря, утечка - это память, которая не освобождена, и, что важнее - которая не может быть освобождена, поскольку утрачен последний указатель на выделенную область. Ссылки: Pro.Lifetime, R.1 (RAII).

Рекомендации:

  • разделять указатели на владельцев и прочих, вместо владельцев, по возможности, использовать решения из stdlib (напр., ifstream вместо FILE*);
  • naked new and delete (?);
  • обращать внимание на функции, возвращающие указатели на вновь созданные объекты (fopen, malloc и т. п.).

P.9: Не тратить пространство и время

(Время, которое уходит на поиск наиболее оптимального решения задачи - потраченным не считается).

Как не надо делать:

struct X {
    char ch;
    int i;
    string s;
    char ch2;

    X& operator=(const X& a);
    X(const X&);
};

X waste(const char* p)
{
    if (!p) throw Nullptr_error{};
    int n = strlen(p);
    auto buf = new char[n];
    if (!buf) throw Allocation_error{};
    for (int i = 0; i < n; ++i) buf[i] = p[i];
    // ... manipulate buffer ...
    X x;
    x.ch = 'a';
    x.s = string(n);    // give x.s space for *p
    for (gsl::index i = 0; i < x.s.size(); ++i) x.s[i] = buf[i];  // copy buf into x.s
    delete[] buf;
    return x;
}

Разумеется, пример карикатурный, но на нём хорошо видны ошибки:

  • в структуре 6 лишних байт;
  • переопределение операторов копирования запрещают семантику перемещения, что замедляет возврат значения;
  • buf выделяется динамически, хотя объявление на стеке было бы в разы быстрее;
  • наконец, после всего этого кордебалета возвращаемое значение игнорируется.

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

Из опыта: существует баланс между пространством и временем. Например, класс с набором обработчиков состояний можно реализовать через switch, а можно через массив ссылок на обработчики. В первом случае теряем производительность, во втором - память.

P.10: Не изменять неизменяемое

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

См: Con: Constants and immutability

P.11: Инкапсулировать инкаспулируемое

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

P.12: Использовать используемое

Компьютеры придуманы для того, чтобы избавить людей от выполнения множества повторяющихся операций. Нет никаких причин, чтобы не использовать эту концепцию в программировании. Существует множество полезных приблуд, облегчающих жизнь программиста - IDE с анализом кода, GTest/GMock, valgrind, TSAN, и т. п. Разумеется, этот список можно продолжить решениями типа Cmake, Travis, Git/SVN, но они не имеют прямого отношения к C++, поэтому выходят за рамки данного документа.

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

P.13: Библиотеки

stdlib, STL и GSL написаны людьми, у которых, скорее всего, было намного больше и профессионализма, и времени чем у вас. Поэтому любой изобретённый вами велосипед с почти 100%й вероятностью будет менее удобным, производительным и безопасным, чем решение из стандартных библиотек. Однако, если вы встретили задачу, для которой не существует библиотечного решения - хорошей практикой будет создать свою библиотеку и использовать её.

I: Интерфейсы

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

I.1: Делать интерфейсы явными

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

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

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

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

I.2: Избегать неконстантных глобальных переменных

Неконстантные глобальные переменные - это скрытые зависимости и источник непредсказуемого поведения (это же относится к переменным внутри namespace). Кроме того, для многопоточных приложений они создают опасность "гонки данных" (data race), когда переменная меняется в то время, когда другой поток выполняет зависимую от неё функцию. Ссылки и указатели на глобальные переменные также создают вероятность гонки данных.

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

I.3: Избегать синглтонов

Хз, что авторы тут вообще хотели сказать. В качестве альтернативного решения приведён синглтон Майерса :) Названием правила должно быть "предпочитать синглтон Майерса классическим с кучей кода инициализации". Не вижу глобальной разницы между синглтоном и единственным экземпляром класса, однако объявление рабочих методов такого класса статическими и обращение к ним через :: положительно сказывается на читабельности программы (см. P.1)

I.4: Интерфейсы должны быть тщательно и жёстко типизированы

Типы - это самая простая и лучшая документация, благодаря их однозначности. Типы проверяются на этапе компиляции, и тщательно типизированный код, как правило, лучше оптимизирован. Удобно, например, передавать функции void*, но при этом нужно постоянно помнить, что она съест, а с чем может вылететь. Если без такой конструкции никак не обойтись, нужно по крайней мере передавать const void*, но оптимальнее использовать шаблоны или variant - в этом случае возможные ошибки будут видны при компиляции.

Как не надо:

set_settings(true, false, 42); // что означают параметры?

Как надо:

alarm_settings s{};
s.enabled = true;
s.displayMode = alarm_settings::mode::spinning_light;
s.frequency = alarm_settings::every_10_seconds;
set_settings(s);

Второй вариант однозначно более читабелен, однако данный пример - это, скорее, перебор: значения параметров легко описываются именами в объявлении функции, которое любая адекватная IDE покажет в процессе написания кода. Хорошей практикой будет вместо передачи ряда параметров bool определить битовый enum, тогда каждый вызов функции будет само-документирован:

enable_lamp_options(lamp_option::on | lamp_option::animate_state_transitions);

Ещё одна хорошая практика - кастомные типы, описывающие что именно означает тот или иной int, напр., using millis_t = int (см. P.1).

Если говорить о передаче некоего времени задержки - то совсем правильно будет использовать std::chrono:duration:

template<class rep, class period>
void blink_led(duration<rep, period> time_to_blink) // good -- accepts any unit
{
    // assuming that millisecond is the smallest relevant unit
    auto milliseconds_to_blink = duration_cast<milliseconds>(time_to_blink);
    // ...
    // do something with milliseconds_to_blink
    // ...
}

void use()
{
    blink_led(2s);
    blink_led(1500ms);
}

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

I.5: Предварительные условия

I.6: Предпочитать Expect() для контроля предварительных условий

I.7: Пост-условия

I.8: Предпочитать Ensure() для контроля пост-условий

Параметры, переданные в функцию, могут принимать значения, приводящие к неправильному выполнению логики функции. Классический пример - sqrt(double), где мы на этапе компиляции никак не можем убедиться, что будет передано положительное значение. Аналогично, сложная функция в каком-то случае может вернуть некорректное значение, что не просто заметить на этапе написания кода. Но помним и про избыточные проверки (см. P.7), нет никакого смысла проверять значение на выходе функции, чтобы потом ещё раз проверить его на входе следующей.

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

Рекомендации использовать Expect и Ensure из GSL устарели, начиная с С++20 это конструкции языка (см. контракты).

НЕ ЯВЛЯЕТСЯ хорошей практикой считать, что unsigned убережёт от отрицательных значений (см. ES.106).

I.9: Если интерфейс шаблонный, документировать параметры

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

Устарело, рекомендует комментировать параметры в стиле уже принятых в C++20 концептов.

I.10: Использовать исключения

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

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

Однако, в ряде случаев исключения применять нецелесообразно. Например, UNIX-функции часто используют errno() для возвращения ошибок, от этого никуда не деться.

Производительность не является причиной для отказа от исключений:

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

I.11: Владение объектом

Оригинальное название правила "Никогда не передавайте владение объектом через сырой указатель или ссылку". Смысл - если объект создаётся в куче, нужно всегда держать в голове, кто "владеет" объектом, т. е., кто должен будет его уничтожить когда он выполнит свою задачу. Решения - использовать умные указатели, owner<t> из GSL, но лучше таки использовать голову.

I.12: Ненулевые указатели

Правило рекомендует объявлять указатели, которые не могут быть нулевыми, с использованием шаблона not_null из GSL. Я рекомендую проверять указатели перед обращением к ним (см. I.5 - I.8).

I.13: Не передавать массивы в виде указателя

Частный случай P.3. Передача указателя не является безопасной, так как нужно ещё каким-то образом (предварительно договорившись или вместе с указателем) передать размер массива. Альтернатива - span или собственные структуры.

I.22: Избегать лишних действий в конструкторах/инициализаторах

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

I.23: Нафига дофига нафигачили?!

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

I.24: Избегать соседних параметров одного типа

В функциях типа copy(T* a, T* b) просто перепутать, что куда копировать. Альтернатива - в случае копирования объявлять источник const (проверка на стадии компиляции), для большого числа параметров - заворачивать в структуру.

I.25: Абстракты - для логики, наследники - для данных

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

I.26: Для кроссплатформенного ABI использовать C

I.27: Использовать Pimpl

Для бинарных библиотек с претензией на кроссплатформенность лучше использовать интерфейсы стиля С (чем проще, тем кроссплатформеннее). Оптимально будет разделить класс на внутреннюю логику и внешний интерфейс (идиома Pimpl). См. GotW #100: Compilation Firewalls (Difficulty: 6/10).

I.30: Инкапсулировать опасный код

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

F: Функции

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

F.1: Упаковывать операции в функции с говорящими именами

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

F.2: Функция должна выполнять одну логическую операцию

Такую функцию проще понять, тестировать и использовать в дальнейшем. Обратить внимание на функции, которые возвращают более одного результата, принимают более 4х параметров и не помещаются на одном экране.

F.3: Функции должны быть короткими и простыми

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

F.4: см. P.5

Если значение функции может быть вычислено на этапе компиляции, объявлять её constexpr (это не означает что на этапе компиляции будет вычислено какое-то значение, это гарантирует проверку того, что значение может быть вычислено).

F.5: Если функция маленькая и быстрая, объявить её inline

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

F.6: Если функция не может бросать исключения, объявить её noexcept

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

Функции constexpr могут бросать исключение в рантайме, т. о., noexcept может применяться и к ним.

Наиболее оптимально применение noexcept к коротким и часто вызываемым функциям.

Дефолтные конструкторы, деструкторы, операции move и swap не должны бросать исключений. См. также C.44.

F.7: Предпочитать аргументы T* / T& смарт-поинтерам

Смарт-поинтеры тянут дополнительную нагрузку на проц, поэтому использовать их имеет смысл только если нужна семантика владения. См. также R.30, F.60.

F.8: Предпочитать чистые функции

Чистая функция возвращает одно и то же значение для одного и того же набора аргументов. Возврат не зависит ни от каких внешних факторов (глобальные переменные, время и т. п.). Простейший пример - использование чистой функции strlen в конструкции for (auto i=0; i<strlen(s); i++) исключит вызов strlen в каждой итерации, результат будет вычислен один раз в начале цикла. Для GCC функция маркируется атрибутом pure: __attribute__ ((pure)) int strlen(char* s).

F.9: Не именовать неиспользуемые параметры

Например, если в переопределённом методе класса-наследника какой-то параметр метода родителя не используется, указание типа без имени подавляет варнинги о неиспользованных переменных: int myClass::dosmth(int used, int).

F.call: Передача аргументов

F.15: Чем проще - тем лучше

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

F.16: Для входных аргументов дешёвые в копировании типы передавать по значению, остальные по const&

Оба способа позволяют обозначить, что функция не меняет аргумент, и оба позволяют инициализацию rvalue'м. "Дешёвые в копировании" - числа, ссылки и т. п.