Что ж, мы плавно выходим на старт второго потока группы «Разработчик С++» и разбираем интересные материалы, которые накопились у преподавателя в свободное от работы и преподавания время. Сегодня рассмотрим (а потом и продолжим) серию материалов, где разбираются отдельные пункты С++ 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)
fishca
20.02.2018 20:55Яркий пример как не надо делатьAlex_info
21.02.2018 00:24+2“Если вы хотите улучшить качество кода в организации, замените все принципы кодинга одной целью: никаких сырых циклов!..
Дословно: если вы пишете сырой цикл, скорее всего вы просто не знаете алгоритмов STL"
это правило не работает когда критична скорость выполнения цикла и программы, алгоритмы STL хороши, но слишком универсальны и потому обычно работают в несколько раз медленнее, чем программы написанные под специализированный класс задач.
С другой стороны, когда скорость не критична, или циклы не велики и не велико число их повторов, то STL вполне достаточно.
Как говорится правила придуманы для того что бы их нарушать.
Со временем мощность вычислительных средств будет расти и STL сможет решать всё больший круг задач, включая критичные ко времени выполнения.dipsy
21.02.2018 04:50потому обычно работают в несколько раз медленнее
А потом включаем -O2, сравниваем, смотрим на свой «специализированный» код, идём за чашечкой кофе, сидим на стуле глядя в монитор и плачем.
Antervis
21.02.2018 07:46это справедливо только в одном случае: std::sort может быть медленнее какой-нибудь пузырьковой сортировки на малом массиве из-за аллокации буфера. Все остальные STL алгоритмы, будучи оптимизированными, производят ровно такой же бинарный код, как и при ручном написании циклов. И даже в приведенном мной примере, лучше реализовать такую сортировку не в виде цикла, а в виде отдельной функции типа bubble_sort(RandomIt begin, RandomIt end)
asi81
21.02.2018 17:19Вероятно имелось в виду, что зная конкретный тип данных можно заменять циклы на специализированные функции. К примеру цикл копирования raw объектов можно ускорить на 2-3 порядка с помощью memcpy.
Antervis
21.02.2018 18:14любой уважающий себя компилятор может заменить копирование pod данных на memcpy и сам
cranium256
21.02.2018 09:09Со временем мощность вычислительных средств будет расти и STL сможет решать всё больший круг задач, включая критичные ко времени выполнения.
Да-да… а потом мы удивляемся: чтой-то у нас Ворд еле ворочается на машинке 10-летнего возраста… а ведь в своё время всё работало нормально.
Кстати, кроме старых машин есть ещё и новые, но специфические компьютеры. Одноплатники, например.
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.Videoman
21.02.2018 11:12Полностью согласен с вашим подходом, считаю что во всем должна быть мера и самое главное правило — код должен быть понятен, даже если его читает средней руки программист.
В случае Правила 1 предпочел бы такой код:
int sum = 0; for (const auto& s : v) sum += s;
Плюсы:
1. В меру декларативный стиль. И компилятору хорошо и человеку.
2. При изменении без в цикл можно без проблем втянуть любой нужный контекст без плясок и страданий.
Дополнение к вашему замечанию к Правилу 2:
там вообще ничего не вернется, указатель просто локально переписывается, а значение не изменяется.Antervis
21.02.2018 15:38Во-вторых, время компиляции второго примера будет чуть больше (умножим на 100500 таких «циклов» в большом проекте).
В случае Правила 1 предпочел бы такой код:
for (const auto& s : v) ...
Плюсы:
1. В меру декларативный стиль. И компилятору хорошо и человеку.
Компилятор разворачивает range-based for в виде цикла с итераторами. Так что компилироваться такой вариант будет не быстрее, чем std::accumulateVideoman
21.02.2018 16:11И??? Не понял вашу мысль.
Моя мысль в том, что из того что компилятор разворачивает range-based for в виде цикла с итераторами, он будет выполняться не медленнее чем std::accumulate, т.к. кроме итераторов есть еще другие полезные действия.
Также, range-based for безопаснее, так как нет возможности перепутать или указать не те (несовместимые) итераторы.
Кроме того, в моей практике, мне это код еще приходится поддерживать — вводить некий контекст и дополнительную логику, а этот синтетический код с std::accumulate в таком случае становиться все более запутанным. Я к тому, что в реальном коде, когда, вдруг, появляется необходимость в дополнительном контексте, то в случае с std::accumulate, вам придется передавать лямбду с уродским синтаксисом. А зачем, если есть range-based for.
cranium256
21.02.2018 16:32Вообще-то я писал про время компиляции, а не про скорость работы. А для того, что бы откомпилировать std::accumulate, компилятору придётся сделать некий объём дополнительной работы по анализу шаблонов и пр.
cranium256
21.02.2018 16:41Да, в примере 1 правила 2 через nread ничего не вернётся. Поторопился. Я как увидел, что в аргумент функции пишется адрес локальной переменной, у меня забрало упало.
Учеников жалко. Не повезло с преподавателем.
Dr_Dash
21.02.2018 20:53Там не только не вернётся, там вообще ошибка
nread = &elemcount; //плохо *nread = elemcount;// хорошо
MaxRokatansky Автор
22.02.2018 00:10Бле, сорян. Я когда постил, то чекал с оригиналом и автоматом оттуда ошибку утащил :(
MaxRokatansky Автор
22.02.2018 00:11С преподавателем-то всё нормуль, а вот с ретранслятором в виде меня — ниочинь, да. Не прочекал на ещё один заход код, а сверил его только с ориджином.
shebdim
22.02.2018 01:14+1В правиле 1, я полагаю, основной посыл в том, чтобы придерживаться stl-стиля в работе с контейнерами и основной упор делается на то, как хотелось бы, чтобы это выглядело в современном коде. Утверждение, что тем, кто пишет давно все ясно, совершенно справедливо, но, полагаю, будущая книга ориентирована на поколение, для которых 14 или 17 полюсы это и есть норма, других версий нет :) Соответственно и подходы к работе с stl навязываются самой библиотекой.
Я сам не очень понял, к чему был комментарий о типе sum, не заметил какой-то подвох?
Цитата Шона не вырвана, он это любит повторять при каждом удобном случае, в разных формулировках, но основной посыл остается прежний — если есть готовый алгоритм, используйте его и не пишите своего.
В правиле 2 примеры не идентичны, они лишь иллюстрируют разный подход. То есть я в приведенных примерах вообще не вижу попыток автора сделать их идентичными ни с точки зрения интерфейса ни с точки зрения реализации. Это не отменяет ошибки в тексте read1 :) Думаю, по примеру кода, который просто иллюстрирует разный подход делать такие резкие выводы об авторе может и рано :)
Правило 5 действительно можно трактовать как призыв писать небольшие функции, как частный случай. Речь в принципе идет о сокращении времени жизни объектов. Это касается циклов, условий и тел функций. Призыв не давать объектам жить дольше, чем это требует задача.
В 6 правиле кажется с примером все нормально, insert возвращает pair<iterator,bool>. result.second имеет тип bool.
Dr_Dash
Пример read1 & read2, мне только один аспект непонятен.
Почему вы возвращаете сам вектор, это же сопряжено с копированием целого вектора строк? Если вам не нравится, что в read2 будет создан вектор, который потом нужно удалять, почему бы не передавать в неё извне ссылку на вектор?
dipsy
А компилятор не оптимизирует? NRVO и всё такое. А даже если нет, то для каких-то случаев, когда эта оптимизация ничего не ускорит (функция вызывается 1 раз при старте программы например) можно ради читабельности и так оставить.
a-tk
Потому что должно отработать RVO.
Цитата из статьи на Википедии про конвенции вызова:
Таким образом, ссылочная переменная создаётся неявно.
Dr_Dash
Благодарю, разобрался, только здесь не конвенция о вызовах, здесь немного другой механизм,
en.wikipedia.org/wiki/Copy_elision
asi81
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 мне сказать сложно — все зависит от уровня оптимизации. Но я часто при реверсинге кода сталкиваюсь с такой конструкцией кода.
asi81
Я поправлюсь. В моем предыдущем примере копирования объектов действительно не будет. Но в
vector b;
b = read2(is);
копирование объектов будет.
Videoman
И здесь, также, не будет копирования. NRVO в функции сразу создаст внешний объект для результата, а b = read2(is); — вызовет перемещение, где, по сути, копируется только указатель.
vanxant
Эмм, всегда был уверен, что вектор это просто указатель технически
asi81
Нет. Это не так.
Попробуйте написать
const int sz = sizeof(vector);
и вы получите от 16 до 40 байт. Зависит от компилятора и debug/release версий.
Класс std::vector не является COW объектом. При операторе копирования происходит копирования всего внутреннего буфера.
vanxant
Попробовал на g++ на ubuntu/lts 64 битном, размер вектора 12 байт. Т.е. 1 указатель плюс 1 инт для быстрого хранения размера.
asi81
Это несколько странный результат так как размер в 64х тоже должен быть 8байтным. Если это 32ный компилятор то еще можно обьяснить указателем и 2мя размерами.
В visualstudio как правило 16 байт в x86. 2 указателя размер и резервед размер.
Dr_Dash
Однако вектор, кроме указателя как такового, это ещё и содержимое массива, и оно, если бы не NRVO тоже копировалось бы из вектора в вектор. А старый вектор пришлось бы ещё и удалять, каждый элемент.