Бобра!

Что ж, мы плавно выходим на старт второго потока группы «Разработчик С++» и разбираем интересные материалы, которые накопились у преподавателя в свободное от работы и преподавания время. Сегодня рассмотрим (а потом и продолжим) серию материалов, где разбираются отдельные пункты С++ 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)

Если есть вопросы, замечания, то ждём их тут или у нас на Дне открытых дверей.

Комментарии (32)


  1. Dr_Dash
    20.02.2018 20:00

    Пример read1 & read2, мне только один аспект непонятен.
    Почему вы возвращаете сам вектор, это же сопряжено с копированием целого вектора строк? Если вам не нравится, что в read2 будет создан вектор, который потом нужно удалять, почему бы не передавать в неё извне ссылку на вектор?


    1. dipsy
      20.02.2018 20:17

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


    1. a-tk
      20.02.2018 20:19
      +1

      Потому что должно отработать RVO.
      Цитата из статьи на Википедии про конвенции вызова:

      Если размер возвращаемого значения функции не больше размера регистра eax, возвращаемое значение сохраняется в регистре eax. Иначе, возвращаемое значение сохраняется на вершине стека, а указатель на вершину стека сохраняется в регистре eax.

      Таким образом, ссылочная переменная создаётся неявно.


      1. Dr_Dash
        20.02.2018 20:56

        Благодарю, разобрался, только здесь не конвенция о вызовах, здесь немного другой механизм,
        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.

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


      1. asi81
        21.02.2018 16:32

        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 мне сказать сложно — все зависит от уровня оптимизации. Но я часто при реверсинге кода сталкиваюсь с такой конструкцией кода.


        1. asi81
          21.02.2018 17:05

          Я поправлюсь. В моем предыдущем примере копирования объектов действительно не будет. Но в
          vector b;
          b = read2(is);
          копирование объектов будет.


          1. Videoman
            21.02.2018 18:07

            И здесь, также, не будет копирования. NRVO в функции сразу создаст внешний объект для результата, а b = read2(is); — вызовет перемещение, где, по сути, копируется только указатель.


    1. vanxant
      20.02.2018 20:58
      -1

      Эмм, всегда был уверен, что вектор это просто указатель технически


      1. asi81
        21.02.2018 16:37

        Нет. Это не так.
        Попробуйте написать
        const int sz = sizeof(vector);
        и вы получите от 16 до 40 байт. Зависит от компилятора и debug/release версий.
        Класс std::vector не является COW объектом. При операторе копирования происходит копирования всего внутреннего буфера.


        1. vanxant
          22.02.2018 10:31

          Попробовал на g++ на ubuntu/lts 64 битном, размер вектора 12 байт. Т.е. 1 указатель плюс 1 инт для быстрого хранения размера.


          1. asi81
            22.02.2018 10:55

            Это несколько странный результат так как размер в 64х тоже должен быть 8байтным. Если это 32ный компилятор то еще можно обьяснить указателем и 2мя размерами.
            В visualstudio как правило 16 байт в x86. 2 указателя размер и резервед размер.


          1. Dr_Dash
            22.02.2018 12:54

            Однако вектор, кроме указателя как такового, это ещё и содержимое массива, и оно, если бы не NRVO тоже копировалось бы из вектора в вектор. А старый вектор пришлось бы ещё и удалять, каждый элемент.


  1. fishca
    20.02.2018 20:55

    Яркий пример как не надо делать


    1. a-tk
      20.02.2018 20:59

      Есть гипотеза, что кодогенерация…


    1. domix32
      21.02.2018 11:26

      Судя по пространству имен это ещё и не очень C++


  1. Alex_info
    21.02.2018 00:24
    +2

    “Если вы хотите улучшить качество кода в организации, замените все принципы кодинга одной целью: никаких сырых циклов!..
    Дословно: если вы пишете сырой цикл, скорее всего вы просто не знаете алгоритмов STL"
    это правило не работает когда критична скорость выполнения цикла и программы, алгоритмы STL хороши, но слишком универсальны и потому обычно работают в несколько раз медленнее, чем программы написанные под специализированный класс задач.
    С другой стороны, когда скорость не критична, или циклы не велики и не велико число их повторов, то STL вполне достаточно.
    Как говорится правила придуманы для того что бы их нарушать.
    Со временем мощность вычислительных средств будет расти и STL сможет решать всё больший круг задач, включая критичные ко времени выполнения.


    1. dipsy
      21.02.2018 04:50

      потому обычно работают в несколько раз медленнее
      А потом включаем -O2, сравниваем, смотрим на свой «специализированный» код, идём за чашечкой кофе, сидим на стуле глядя в монитор и плачем.


    1. Antervis
      21.02.2018 07:46

      это справедливо только в одном случае: std::sort может быть медленнее какой-нибудь пузырьковой сортировки на малом массиве из-за аллокации буфера. Все остальные STL алгоритмы, будучи оптимизированными, производят ровно такой же бинарный код, как и при ручном написании циклов. И даже в приведенном мной примере, лучше реализовать такую сортировку не в виде цикла, а в виде отдельной функции типа bubble_sort(RandomIt begin, RandomIt end)


      1. asi81
        21.02.2018 17:19

        Вероятно имелось в виду, что зная конкретный тип данных можно заменять циклы на специализированные функции. К примеру цикл копирования raw объектов можно ускорить на 2-3 порядка с помощью memcpy.


        1. Antervis
          21.02.2018 18:14

          любой уважающий себя компилятор может заменить копирование pod данных на memcpy и сам


    1. cranium256
      21.02.2018 09:09

      Со временем мощность вычислительных средств будет расти и STL сможет решать всё больший круг задач, включая критичные ко времени выполнения.
      Да-да… а потом мы удивляемся: чтой-то у нас Ворд еле ворочается на машинке 10-летнего возраста… а ведь в своё время всё работало нормально.

      Кстати, кроме старых машин есть ещё и новые, но специфические компьютеры. Одноплатники, например.


  1. cranium256
    21.02.2018 10:29
    +1

    Есть некоторые сомнения в качестве исходной статьи. Автор взял список С++ 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.


    1. Videoman
      21.02.2018 11:12

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

      В случае Правила 1 предпочел бы такой код:

      int sum = 0;
      
      for (const auto& s : v)
          sum += s;

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

      Дополнение к вашему замечанию к Правилу 2:
      там вообще ничего не вернется, указатель просто локально переписывается, а значение не изменяется.


      1. Antervis
        21.02.2018 15:38

        Во-вторых, время компиляции второго примера будет чуть больше (умножим на 100500 таких «циклов» в большом проекте).

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

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

        Компилятор разворачивает range-based for в виде цикла с итераторами. Так что компилироваться такой вариант будет не быстрее, чем std::accumulate


        1. Videoman
          21.02.2018 16:11

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


        1. cranium256
          21.02.2018 16:32

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


      1. cranium256
        21.02.2018 16:41

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


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


        1. Dr_Dash
          21.02.2018 20:53

          Там не только не вернётся, там вообще ошибка

          nread = &elemcount; //плохо
          *nread = elemcount;// хорошо


          1. cranium256
            21.02.2018 22:27

            Так о том и речь.


          1. MaxRokatansky Автор
            22.02.2018 00:10

            Бле, сорян. Я когда постил, то чекал с оригиналом и автоматом оттуда ошибку утащил :(


        1. MaxRokatansky Автор
          22.02.2018 00:11

          С преподавателем-то всё нормуль, а вот с ретранслятором в виде меня — ниочинь, да. Не прочекал на ещё один заход код, а сверил его только с ориджином.


    1. shebdim
      22.02.2018 01:14
      +1

      В правиле 1, я полагаю, основной посыл в том, чтобы придерживаться stl-стиля в работе с контейнерами и основной упор делается на то, как хотелось бы, чтобы это выглядело в современном коде. Утверждение, что тем, кто пишет давно все ясно, совершенно справедливо, но, полагаю, будущая книга ориентирована на поколение, для которых 14 или 17 полюсы это и есть норма, других версий нет :) Соответственно и подходы к работе с stl навязываются самой библиотекой.

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

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

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

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

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