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 размещается в памяти, хотя тут надо на стеке;
  • наконец, после всего этого возвращаемое значение игнорируется.

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

P.10: Предпочитать неизменные данные изменяемым

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

См: Con: Constants and immutability

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

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

Пример - использовать конструкции stdlib, которые написали люди, у которых однозначно было больше и времени, и опыта.

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

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

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

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

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

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

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