Основные принципы C++: Правила выражений и операторов

http://www.modernescpp.com/index.php/component/jaggyblog/c-core-guidelines-rules-for-expressions-and-statements#h0-1-es-1-prefer-the-standard-library-to-other-libraries-and-to-handcrafted-code
  • Перевод
Бобра!

Что ж, мы плавно выходим на старт второго потока группы «Разработчик С++» и разбираем интересные материалы, которые накопились у преподавателя в свободное от работы и преподавания время. Сегодня рассмотрим (а потом и продолжим) серию материалов, где разбираются отдельные пункты С++ Core Guidelines.

Поехали.

В C++ Core Guidelines много правил, посвященных выражениям и операторам. Если быть точным, то более 50 правил посвящено объявлениям, выражениям, операторам и арифметическим выражениям.



*перевод
Информативные названия

Оптимальная длина переменных

  • Не должны быть слишком длинными (maximimNumberOfPointsInModernOlympics.) или слишком короткими (например, x, x1)
  • Длинные названия сложно печатать, короткие названия недостаточно информативны..
  • Дебажить программы с названиями от 8 до 20 символов гораздо проще
  • Гайдлайны не заставляют вас срочно менять названия переменных на имена из 9-15 или 10-16 символов. Но если вы найдете в своем коде более короткие названия, убедитесь, что они достаточно информативны.

Слишком длинные: numberOfPeopleOnTheUsOlympicTeam; numberOfSeatsInTheStadium; maximumNumberOfPointsInModernOlympics
Слишком короткие: n; np; ntmn; ns; nslsd; m; mp; max; points
В самый раз: numTeamMembers, teamMembersCount



Существует два правила, являющихся общими:

Правило 1: Отдайте предпочтение стандартным библиотекам перед прочими библиотеками и “самописным” кодом

Нет смысла писать сырой цикл для суммирования вектора чисел:

int max = v.size();             // плохо: пространно, цель не ясна
double sum = 0.0;
for (int i = 0; i < max; ++i)
    sum = sum + v[i];

Просто используйте алгоритм std::accumulate из STL.

auto sum = std::accumulate(begin(a), end(a), 0.0);   // хорошо

Это правило напомнило мне слова Шона Парент (Sean Parent) с CppCon 2013: “Если вы хотите улучшить качество кода в организации, замените все принципы кодинга одной целью: никаких сырых циклов!”.

Дословно: если вы пишете сырой цикл, скорее всего вы просто не знаете алгоритмов STL.

Правило 2: Отдайте предпочтение подходящим абстракциям перед непосредственным использованием языковых особенностей

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

Понять функционал мешали неправильные абстракции, на которых он был построен.
К примеру, посмотрим на самодельную функцию для чтения std::istream:

char** read1(istream& is, int maxelem, int maxstring, int* nread)   // плохо: пространно и разрозненно
{
    auto res = new char*[maxelem];
    int elemcount = 0;
    while (is && elemcount < maxelem) {
        auto s = new char[maxstring];
        is.read(s, maxstring);
        res[elemcount++] = s;
    }
    *nread = elemcount;
    return res;
}

И, в сравнении, насколько проще воспринимается следующая функция:

vector<string> read2(istream& is)   // хорошо
{
    vector<string> res;
    for (string s; is >> s;)
        res.push_back(s);
    return res;
}

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

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

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

Вот первые шесть правил.
(нумерация идёт как в статье. Автор пропустил пункты 3 и 4 т.к. они не соответствуют тематике)

Правило 5: Придерживайтесь небольшой области видимости

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

Правило 6: Объявляйте имена в инициализаторах и условиях for-оператора, чтобы ограничить область видимости

Мы могли объявлять переменную в операторе for еще со времен первого С++ стандарта. А в C++17 мы можем объявлять переменные и в операторах if и switch.

std::map<int,std::string> myMap;

if (auto result = myMap.insert(value); result.second){  // (1)
    useResult(result.first);  
    // ...
} 
else{
    // ...
} // результат автоматически уничтожается                 // (2)

Переменная result (1) действительна только внутри веток if и else оператора if. Поэтому result не будет засорять внешнюю область видимости и автоматически уничтожится (2). Такая особенность есть только в C++17, раньше result нужно было бы объявить во внешней области видимости (3).

std::map<int,std::string> myMap;
auto result = myMap.insert(value)   // (3)
if (result.second){  
    useResult(result.first);  
    // ...
} 
else{
    // ...
}

Правило 7: Общие и локальные имена должны быть короче, чем редкие и нелокальные

Правило может показаться странным, но мы уже привыкли. Присваивая переменным имена i, j и Т, мы сразу даем понять, что i и j — это индексы, а T — тип параметра шаблона.

template<typename T>    // good
void print(ostream& os, const vector<T>& v)
{
    for (int i = 0; i < v.size(); ++i)
        os << v[i] << '\n';
}

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

Правило 8: Избегайте похожих имен

А вам удастся прочитать этот пример без замешательства?

if (readable(i1 + l1 + ol + o1 + o0 + ol + o1 + I0 + l0)) surprise();

Если честно, я часто с трудом различаю цифру 0 и заглавную букву O. Из-за шрифта они могут выглядеть почти одинаково. Два года назад мне потребовалось очень много времени, чтобы залогиниться на сервер. Просто потому что автоматически сгенерированный пароль содержал символ O.

Правило 9: Не используйте имена, написанные ПОЛНОСТЬЮ_КАПСОМ

Если вы пишете названия ПОЛНОСТЬЮ_КАПСОМ, то будьте готовы столкнуться с заменой на макросы — именно в них он часто используется. В части программы, представленной ниже, есть небольшой сюрприз:

// где-нибудь в заголовке:
#define NE !=

// где-нибудь в другом заголовке:
enum Coord { N, NE, NW, S, SE, SW, E, W };

// где-нибудь еще в .cpp грустного программиста:
switch (direction) {
case N:
    // ...
case NE:
    // ...
// ...
}

Правило 10: Объявляйте (только) одно имя за раз

Приведу два примера. Заметили обе проблемы?

char* p, p2;
char a = 'a';
p = &a;
p2 = a;                              // (1)

int a = 7, b = 9, c, d = 10, e = 3;  // (2)

p2 — просто char (1) и c не инициализирован (2).

В C++17 для нас есть одно исключение из этого правила: структурированное связывание.
Теперь я могу сделать if выражение с инициализатором из правила 6 еще более удобным для чтения.

std::map<int,std::string> myMap;

if (auto [iter, succeeded] = myMap.insert(value); succedded){  // (1)
    useResult(iter);  
    // ...
} 
else{
    // ...
} // iter и succeeded автоматически уничтожаются           // (2)

THE END (to be continued)

Если есть вопросы, замечания, то ждём их тут или у нас на Дне открытых дверей.
Отус 171,26
Профессиональные онлайн-курсы для разработчиков
Поделиться публикацией
Комментарии 32
  • 0
    Пример read1 & read2, мне только один аспект непонятен.
    Почему вы возвращаете сам вектор, это же сопряжено с копированием целого вектора строк? Если вам не нравится, что в read2 будет создан вектор, который потом нужно удалять, почему бы не передавать в неё извне ссылку на вектор?
    • 0
      А компилятор не оптимизирует? NRVO и всё такое. А даже если нет, то для каких-то случаев, когда эта оптимизация ничего не ускорит (функция вызывается 1 раз при старте программы например) можно ради читабельности и так оставить.
      • +3
        Потому что должно отработать RVO.
        Цитата из статьи на Википедии про конвенции вызова:
        Если размер возвращаемого значения функции не больше размера регистра eax, возвращаемое значение сохраняется в регистре eax. Иначе, возвращаемое значение сохраняется на вершине стека, а указатель на вершину стека сохраняется в регистре eax.

        Таким образом, ссылочная переменная создаётся неявно.
        • 0
          Благодарю, разобрался, только здесь не конвенция о вызовах, здесь немного другой механизм,
          en.wikipedia.org/wiki/Copy_elision

          To avoid this, an implementation may create a hidden object in the caller's stack frame, and pass the address of this object to the function.

          Перевод
          Чтобы избежать этого (т.е. копирования объектов типа класс ) может быть неявно создан объект требуемого типа в стековом кадре вызывающей функции, после чего адрес этого объекта передаётся в вызываемую функцию.
          • 0
            Dr_Dash прав по поводу копирования. Если точнее конструкция
            vector b = read2(is);
            будет компилироваться в следующий псевдокод

            vector b;
            vector stack_tmp_var; //переменная на стеке которая нужна для вызова read2
            vector* pvec = read2(&stack_tmp_var, is) // pvec — это и есть eax
            b = stack_tmp_var // или вероятнее в декомпиляторе будет vector.operator_assign(&b,pvec)

            То-есть операция копирования все равно будет происходить. По крайней мере в debug версии приложения будет сделано так. Какой код будет в версии release мне сказать сложно — все зависит от уровня оптимизации. Но я часто при реверсинге кода сталкиваюсь с такой конструкцией кода.
            • 0
              Я поправлюсь. В моем предыдущем примере копирования объектов действительно не будет. Но в
              vector b;
              b = read2(is);
              копирование объектов будет.
              • 0
                И здесь, также, не будет копирования. NRVO в функции сразу создаст внешний объект для результата, а b = read2(is); — вызовет перемещение, где, по сути, копируется только указатель.
          • –1
            Эмм, всегда был уверен, что вектор это просто указатель технически
            • 0
              Нет. Это не так.
              Попробуйте написать
              const int sz = sizeof(vector);
              и вы получите от 16 до 40 байт. Зависит от компилятора и debug/release версий.
              Класс std::vector не является COW объектом. При операторе копирования происходит копирования всего внутреннего буфера.
              • 0
                Попробовал на g++ на ubuntu/lts 64 битном, размер вектора 12 байт. Т.е. 1 указатель плюс 1 инт для быстрого хранения размера.
                • 0
                  Это несколько странный результат так как размер в 64х тоже должен быть 8байтным. Если это 32ный компилятор то еще можно обьяснить указателем и 2мя размерами.
                  В visualstudio как правило 16 байт в x86. 2 указателя размер и резервед размер.
                  • 0
                    Однако вектор, кроме указателя как такового, это ещё и содержимое массива, и оно, если бы не NRVO тоже копировалось бы из вектора в вектор. А старый вектор пришлось бы ещё и удалять, каждый элемент.
            • +2
              Яркий пример как не надо делать
              • 0
                Есть гипотеза, что кодогенерация…
                • +2
                  Судя по пространству имен это ещё и не очень C++
                • +2
                  “Если вы хотите улучшить качество кода в организации, замените все принципы кодинга одной целью: никаких сырых циклов!..
                  Дословно: если вы пишете сырой цикл, скорее всего вы просто не знаете алгоритмов STL"
                  это правило не работает когда критична скорость выполнения цикла и программы, алгоритмы STL хороши, но слишком универсальны и потому обычно работают в несколько раз медленнее, чем программы написанные под специализированный класс задач.
                  С другой стороны, когда скорость не критична, или циклы не велики и не велико число их повторов, то STL вполне достаточно.
                  Как говорится правила придуманы для того что бы их нарушать.
                  Со временем мощность вычислительных средств будет расти и STL сможет решать всё больший круг задач, включая критичные ко времени выполнения.
                  • +2
                    потому обычно работают в несколько раз медленнее
                    А потом включаем -O2, сравниваем, смотрим на свой «специализированный» код, идём за чашечкой кофе, сидим на стуле глядя в монитор и плачем.
                    • 0
                      это справедливо только в одном случае: std::sort может быть медленнее какой-нибудь пузырьковой сортировки на малом массиве из-за аллокации буфера. Все остальные STL алгоритмы, будучи оптимизированными, производят ровно такой же бинарный код, как и при ручном написании циклов. И даже в приведенном мной примере, лучше реализовать такую сортировку не в виде цикла, а в виде отдельной функции типа bubble_sort(RandomIt begin, RandomIt end)
                      • 0
                        Вероятно имелось в виду, что зная конкретный тип данных можно заменять циклы на специализированные функции. К примеру цикл копирования raw объектов можно ускорить на 2-3 порядка с помощью memcpy.
                        • 0
                          любой уважающий себя компилятор может заменить копирование pod данных на memcpy и сам
                      • 0
                        Со временем мощность вычислительных средств будет расти и STL сможет решать всё больший круг задач, включая критичные ко времени выполнения.
                        Да-да… а потом мы удивляемся: чтой-то у нас Ворд еле ворочается на машинке 10-летнего возраста… а ведь в своё время всё работало нормально.

                        Кстати, кроме старых машин есть ещё и новые, но специфические компьютеры. Одноплатники, например.
                      • +2
                        Есть некоторые сомнения в качестве исходной статьи. Автор взял список С++ Core Guidelines и, на мой взгляд неудачно, откомментировал.

                        Правило 1. Пример с циклом не показателен. Во-первых, первый пример абсолютно ясен, если вы сколько-нибудь долго пишете на C/C++. Во-вторых, время компиляции второго примера будет чуть больше (умножим на 100500 таких «циклов» в большом проекте). Кстати, какого типа будет sum во втором примере? — ага, double.

                        «Никаких сырых циклов» — вообще бред. Скорее всего цитата просто выдернута из контекста.

                        Правило 2. Результат работы функций read1 и read2 будут различны. Первая считывает в каждый массив char ровно по maxstring символов (за исключением последнего куска данных), включая пробельные символы и переводы строк. Концевой '\0' в массивы не добавляется. Вторая разбивает входные данные на строки по разделителям (пробельные символы и концы строк по умолчанию), которые в выходные данные не попадают.

                        Кроме того, в первой функции в параметре nread возвращается адрес локальной переменной. Это к слову о квалификации автора как программиста C++.

                        И «никаких сырых циклов»!

                        Правило 5. Спорно. Зависит как от конкретного кода, так и от размера экрана. И совершенно не факт, что определив новые логические сущности, разделив «на функции и объекты с методами», вы не получите двукратное увеличение объёма кода, разделённого на несколько файлов, с оверхедом в виде вызова функций и методов.

                        Кстати, это уже к автору перевода, термин «область видимости» имеет несколько другую семантику, если имеется ввиду язык программирования. Правило 5 лучше бы звучало как «пишите по возможности небольшие функции».

                        Правило 6. Оператор if (result.second) не скомпилируется в обоих случаях, поскольку имеет тип std::string, который неявно не приводится к bool.
                        • 0
                          Полностью согласен с вашим подходом, считаю что во всем должна быть мера и самое главное правило — код должен быть понятен, даже если его читает средней руки программист.

                          В случае Правила 1 предпочел бы такой код:
                          int sum = 0;
                          
                          for (const auto& s : v)
                              sum += s;

                          Плюсы:
                          1. В меру декларативный стиль. И компилятору хорошо и человеку.
                          2. При изменении без в цикл можно без проблем втянуть любой нужный контекст без плясок и страданий.

                          Дополнение к вашему замечанию к Правилу 2:
                          там вообще ничего не вернется, указатель просто локально переписывается, а значение не изменяется.
                          • 0
                            Во-вторых, время компиляции второго примера будет чуть больше (умножим на 100500 таких «циклов» в большом проекте).

                            В случае Правила 1 предпочел бы такой код:
                            for (const auto& s : v) ...

                            Плюсы:
                            1. В меру декларативный стиль. И компилятору хорошо и человеку.

                            Компилятор разворачивает range-based for в виде цикла с итераторами. Так что компилироваться такой вариант будет не быстрее, чем std::accumulate
                            • 0
                              И??? Не понял вашу мысль.
                              Моя мысль в том, что из того что компилятор разворачивает range-based for в виде цикла с итераторами, он будет выполняться не медленнее чем std::accumulate, т.к. кроме итераторов есть еще другие полезные действия.
                              Также, range-based for безопаснее, так как нет возможности перепутать или указать не те (несовместимые) итераторы.
                              Кроме того, в моей практике, мне это код еще приходится поддерживать — вводить некий контекст и дополнительную логику, а этот синтетический код с std::accumulate в таком случае становиться все более запутанным. Я к тому, что в реальном коде, когда, вдруг, появляется необходимость в дополнительном контексте, то в случае с std::accumulate, вам придется передавать лямбду с уродским синтаксисом. А зачем, если есть range-based for.
                              • 0

                                Вообще-то я писал про время компиляции, а не про скорость работы. А для того, что бы откомпилировать std::accumulate, компилятору придётся сделать некий объём дополнительной работы по анализу шаблонов и пр.

                              • 0

                                Да, в примере 1 правила 2 через nread ничего не вернётся. Поторопился. Я как увидел, что в аргумент функции пишется адрес локальной переменной, у меня забрало упало.


                                Учеников жалко. Не повезло с преподавателем.

                                • 0
                                  Там не только не вернётся, там вообще ошибка
                                  nread = &elemcount; //плохо
                                  *nread = elemcount;// хорошо
                                  • 0
                                    Так о том и речь.
                                    • 0
                                      Бле, сорян. Я когда постил, то чекал с оригиналом и автоматом оттуда ошибку утащил :(
                                    • 0
                                      С преподавателем-то всё нормуль, а вот с ретранслятором в виде меня — ниочинь, да. Не прочекал на ещё один заход код, а сверил его только с ориджином.
                                  • +1
                                    В правиле 1, я полагаю, основной посыл в том, чтобы придерживаться stl-стиля в работе с контейнерами и основной упор делается на то, как хотелось бы, чтобы это выглядело в современном коде. Утверждение, что тем, кто пишет давно все ясно, совершенно справедливо, но, полагаю, будущая книга ориентирована на поколение, для которых 14 или 17 полюсы это и есть норма, других версий нет :) Соответственно и подходы к работе с stl навязываются самой библиотекой.

                                    Я сам не очень понял, к чему был комментарий о типе sum, не заметил какой-то подвох?

                                    Цитата Шона не вырвана, он это любит повторять при каждом удобном случае, в разных формулировках, но основной посыл остается прежний — если есть готовый алгоритм, используйте его и не пишите своего.

                                    В правиле 2 примеры не идентичны, они лишь иллюстрируют разный подход. То есть я в приведенных примерах вообще не вижу попыток автора сделать их идентичными ни с точки зрения интерфейса ни с точки зрения реализации. Это не отменяет ошибки в тексте read1 :) Думаю, по примеру кода, который просто иллюстрирует разный подход делать такие резкие выводы об авторе может и рано :)

                                    Правило 5 действительно можно трактовать как призыв писать небольшие функции, как частный случай. Речь в принципе идет о сокращении времени жизни объектов. Это касается циклов, условий и тел функций. Призыв не давать объектам жить дольше, чем это требует задача.

                                    В 6 правиле кажется с примером все нормально, insert возвращает pair<iterator,bool>. result.second имеет тип bool.

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

                                  Самое читаемое