![](https://habrastorage.org/files/da2/e08/e17/da2e08e172854d3ea0b0db284a03685a.png)
Если бы язык С был оружием
От автора: Наброски для этой статьи появились еще в начале 2015 года, правда, до публикации материалов дело так и не дошло. Наконец, решив, что в ящике моего письменного стола от вышеупомянутого «черновика» не будет никакой пользы, представляю его вашему вниманию в исходном виде. Единственное, что изменилось в тексте – год, с 2015 на 2016.
И я всегда рад услышать комментарии по поводу необходимых исправлений, уточнений или даже ваши жалобы.
Итак, статья ...
Первое правило программирования на С – не используйте его, если можно обойтись другими инструментами.
Когда найти альтернативный метод не удается, самое время вспомнить о современных заповедях программиста.
Язык программирования С известен примерно с начала 1970-х гг. Специалистам приходилось «изучать С» на разных стадиях его эволюции, причем более близкое знакомство нередко приводило в тупик. Так у разных программистов было свое представление о мире С, обусловленное первым опытом применения алгоритмов данного языка.
Столкнувшись с программированием на С, очень важно не застрять на уровне «истин, усвоенных в 80-х/90-х».
Если вы читаете эту статью, скорее всего, вы работаете на современных платформах, придерживаетесь актуальных стандартов и мне не нужно ссылаться на бесконечное множество условностей для старого софта. Бессмысленно увековечивать древние стандарты только потому что отдельные компании не удосужились обновить системы, которым, в лучшем случае, лет 20.
Введение
Стандарт C99 (здесь C99 – «Стандарт программирования на С 1999 года»; C11 — «Стандарт программирования на С 2011 года», а, значит, 11>99).
clang, default
- По умолчанию clang использует расширенную версию C11 (
режим GNU C11
), поэтому дополнительные опции для современных программ не требуются. - Если вам нужен стандарт
C11
, укажите-std=c11;
если вы предпочитаете работать со стандартом C99, помечайте -std=c99
. - clang компилирует исходные файлы быстрее, чем
gcc
. - Выбирая
gcc
, важно указывать-std=c99
или-std=c11
gcc
создает исходные файлы медленнее, чемclang
, но иногда генерирует более быстрый код. Показательна сравнительная характеристика производительности и результатов регрессионного тестирования.- По умолчанию
gcc-5
работает в режимеGNU С11
(как иclang
), но если вам нужны именно C11 или C99, опять же придется указать-std=c11
или-std=c99
.
Оптимизация
-O2, -О3.
Обычно вам подходит
-O2
, но иногда нужен -O3
.Протестируйте оба варианта (в том числе и для разных компиляторов), а затем сохраните самые эффективные исполняемые файлы.— Os
-Os
выручает, когда появляются вопросы с производительностью кэш-памяти (и это неспроста).Предупреждения (Warnings)
-Wall -Wextra -pedantic
В последних версиях компиляторов предлагается опция
-Wpedantic
, хотя при необходимости можно обращаться и к древней -pedantic
, в частности, для расширения возможностей обратной совместимости.На этапе тестирования добавьте
-Werror
и -Wshadow
для всех платформ.Обращение к
-Werror
может несколько затруднить процесс программирования, так как разные платформы, компиляторы и библиотеки могут выдавать те или иные предупреждения. Не думаю, что вам захочется пренебречь разработками заказчика только потому, что его версия GCC
на платформе, с которой вы раньше не сталкивались, атакует все новыми и новыми злобными уведомлениями.Среди дополнительных забавных опций следует упомянуть
Wstrict-overflow -fno-strict-aliasing
.Либо вы включаете
-fno-strict-aliasing
, либо вы сможете работать с объектами исключительно в том виде, в котором они создавались. Так как программирование на С предполагает применение различных псевдонимов, лучше выбирать -fno-strict-aliasing
, если только речь не идет о необходимости контролировать все дерево исходных текстов.Чтобы
Clang
не отправлял предупреждения о том, что вы пользуетесь, да-да, подходящим синтаксисом, просто добавьте -Wno-missing-field-initializers
.в
GCC 4.7.0
и более поздних версиях данное странное предупреждение устранено.Разработка
Compilation units
Для разработки проектов на С чаще всего просто выделяют в каждом исходном файла – объектный, а затем компонуют полученные объекты в одно целое. Подобная схема прекрасно подходит для поэтапных разработок, но вряд ли ее можно назвать оптимальной, когда речь идет о производительности и оптимизации. При таком подходе ваш компилятор не распознает необходимость оптимизации, анализируя множество объектных файлов.
LTO — Link Time Optimization
LTO
осуществляет «анализ и оптимизацию источника в рамках неполадок с единицами компиляции», создавая аннотации для объектных файлов в виде промежуточных пометок, что позволяет в процессе слияния объектов вносить соответствующие корректировки в исходные данные.LTO
может существенно замедлить процесс слияния. Выручает make -j
, но только в том случае, если разработка состоит из самостоятельных, не связанных с друг другом конечных исполнителей (.a, .so, .dylib, исполняемые файлы тестировки, исполняемые приложения и т.д.).clang LTO.
GCC LTO.
К 2016,
clang и gcc
позаботились о создании вспомогательной LTO
, воспользоваться преимуществами которой вы сможете, добавив -flto
в список команд при компиляции объектов и итоговом слиянии элементов библиотеки/программы. Однако за LTO
по-прежнему нужен глаз да глаз. Иногда, если в программе применяется код, запускаемый не напрямую, а посредством дополнительных библиотек, LTO
может исключить соответствующие функции или код, ведь в ходе общего анализа утилита обнаруживает, что они не используются, а, значит, не нужны в финальной версии продукта.Arch
-march=native
Позвольте компилятору задействовать все функции вашего процессора и запомните: тестирование производительности и регрессионное тестирование важны (с последующим сравнительным анализом результатов для различных компиляторов и/или их версий), поскольку с их помощью можно убедиться, что элементы оптимизации не имеют негативных побочных эффектов.
-msse2 и -msse4.2
могут понадобиться, если вы работаете с опциями, подготовленными другими разработчиками.Создание кода
Типы(Types)
Если вы обнаружили в новом коде что-то вроде
char, int, short, long или unsigned
, вот вам и ошибки.В современных программах необходимо указывать
#include <stdint.h>
и только потом выбирать стандартные типы данных.Подробные описания вы найдете здесь: stdint.h specification.
Среди наиболее распространенных стандартных типов данных выделяются следующие:
int8_t, int16_t, int32_t, int64_t
— знаковые целые;uint8_t, uint16_t, uint32_t, uint64_t
— беззнаковые целые;float
— 32-битный стандарт с плавающей точкой;double
— 64-битный стандарт с плавающей точкой.
Обратите внимание: больше никаких
char
. Обычно на языке программирования С команду char
не только называют, но и используют неправильно.Разработчики ПО то и дело употребляют команду
char
для обозначения «байта», даже когда выполняются беззнаковые байтовые операции. Гораздо правильнее для отдельных беззнаковых байтовых/октетных величин указывать uint8_t
, а для последовательности беззнаковых байтовых/октетных величин выбирать uint8_t
*.Стоит ли ссылаться на int
Некоторые из наших читателей признаются, что просто обожают
int
, о чем вам поведают их холодные застывшие пальцы. Стоит отметить, что технически невозможно программировать корректно, если размеры типов данных изменяются, как им вздумается.Также ознакомьтесь с Обоснованием, озвученным в ходе обсуждения inttypes.h: тут недвусмысленно поясняется, почему небезопасно применять типы нефиксированной ширины. Если вы уже подметили, что в процессе разработки на отдельных платформах
int
16-битный, на других — 32-битный, а также протестировали проблемные зоны на 16 и 32 битах для каждого случая использования int
, можете продолжать в том же духе.Остальным же, кто еще не освоил премудрость удерживания в голове целых комплексов технических условий для платформ с многоуровневой структурой при выполнении очередной головоломки, советую остановиться на типах фиксированной ширины, что автоматически позволит писать более правильный код с заметно меньшим количеством концептуальных погрешностей, для тестирования которого не потребуются дополнительные усилия. Или, как кратко говорится в описании: «правило ISO C по продвижению стандартных целочисленных данных может привести к совершенно неожиданным изменениям».
Без удачи тут не обойтись.
Исключение из правила «никогда не используйте char
»
Единственный случай, когда в 2016 году можно обращаться к команде
char
, это, если выбранный API запрашивает char
(например, strncat, printf'ing "%s", ...
) или если вы задаете строки исключительно для чтения (например, const char *hello = "hello";
), потому что на языке программирования С строковые литералы («hello») выглядят, как char
[].КРОМЕ ТОГО: В С11 предусмотрена поддержка родного unicode, а для UTF-8 строковых литералов по-прежнему используется
char
, даже если приходится работать с мультибайтовыми последовательностями вроде const char *abcgrr = u8"abc";
.Исключение из правила «никогда не используйте {int,long,etc}
»
Если вы обращаетесь к функциям с типами результата или родными параметрами, используйте типы в соответствии с классом функции или характеристиками API.
Знаковость (Signedness)
Не вздумайте использовать
unsigned
в вашем коде. Теперь вы знаете, как написать приличный код без несуразных условностей C с многочисленными типами данных, которые не только делают содержание нечитабельным, но и ставят под вопрос эффективность использования готового продукта. Кому захочется вводить unsigned long long int
, если можно ограничиться простым uint64_t
? Файлы типа <stdint.h> куда конкретнее и точнее по смыслу, они лучше передают намерения автора, компактны – что немаловажно и для эксплуатации, и для читабельности.Целочисленные указатели
Возможно, кто-то из вас возразит: «Но как же без указателей для
long
, без них же вся математика накроется!»Вы, конечно, можете и такое заявить, но кто говорит, что утверждение истинно?
Правильный тип для указателей в данном случае —
uintptr_t
, он задается файлами <stdint.h>
. В то же время важно отметить, что весьма полезный ptrdiff_t
определяется stddef.h
.Вместо:
long diff = (long)ptrOld - (long)ptrNew;
Используйте:
ptrdiff_t diff = (uintptr_t)ptrOld - (uintptr_t)ptrNew;
А также:
printf("%p is unaligned by %" PRIuPTR " bytes.\n", (void *)p, ((uintptr_t)somePtr & (sizeof(void *) - 1)));
Системно-зависимые типы данных
Вы все еще спорите, будто «на 32-битной платформе мне нужны 32-битные
long
, а на 64-ной — 64-битные!».Если опустить рассуждения, в ходе которых вы явно затрудняетесь объяснить причину использования в коде двух разных размеров в зависимости от платформы, думаю, в итоге вам все же не захочется зацикливаться на
long
, ориентированных на системно-зависимые типы данных.В подобных ситуациях рационально обращаться к
intptr_t
– целочисленному типу данных, отвечающему за хранение значения указателя для вашей платформы.На современных 32-битных платформах
intptr_t
трансформируется в int32_t
.На современных 64-битных платформах
intptr_t
приобретает вид int64_t
.Также
intptr_t
встречается в варианте uintptr_t
.Для хранения информации о смещении указателя используйте
ptrdiff_t
– именно этот тип данных позволяет запоминать параметры вычитаемых указателей.Максимальное значение величин
Вы ищете целочисленный тип данных, способный обрабатывать любые целые значения в вашей системе?
Как правило, программисты предпочитают самые известные альтернативы, в частности, неказистый
uint64_t
, а ведь есть более эффективное техническое решение, благодаря которому любая переменная может применяться для хранения всевозможных значений. Безопасное хранение целочисленных данных гарантирует intmax_t
(или uintmax_t
). Вы можете доверить любую знаковую величину intmax_t
, будучи уверенными, что точность данных от этого не пострадает. Аналогично и с беззнаковыми целыми, делегированными uintmax_t
.Другой тип данных
Если мы говорим о широко распространённых системно-зависимых типах данных,
size_t
, гарантируемый stddef.h
занимает первое место в списке фаворитов.По сути,
size_t
– что-то вроде «целой величины, способной хранить огромные индексы массива», а, значит, ему под силу фиксировать внушительные показатели смещения в создаваемой программе.На практике
size_t
выступает в роли типа результата для оператора sizeof.В любом случае на современных платформах
size_t
обладает, практически, теми же характеристиками, что и uintptr_t
, а потому на 32-битных версиях size_t
трансформируется в uint32_t
, а на 64-битных – в uint64_t
.Существует также
ssize_t
, который представляет собой знаковый size_t
, используемый в качестве типа результата для функций библиотеки – в случае ошибки получаем 1. (Примечание: ssize_t
принадлежит пакету POSIX и не подходит для Windows).Так стоит ли задействовать
size_t
для произвольных системно-зависимых размеров, задавая параметры собственных функций? Технически, size_t
– тип результата sizeof
, поэтому любые функции, определяющие размер величины в виде конкретного количества байтов, могут принимать вид size_t
.Другие области применения:
size_t
— тип аргумента для функции malloc, а ssize_t
– тип результата для read()
и write()
(за исключением интерфейсов Windows, в которых ssize_t
не предусмотрен и для значений результата применяется только int).Типы вывода данных на печать (Printing Types)
Не ссылайтесь на типы данных во время печати. Всегда используйте соответствующие указатели типа, как советуют на inttypes.h.
В данный перечень попадают (конечно, это лишь краткая выдержка):
- size_t — %zu
- ssize_t — %zd
- ptrdiff_t — %td
- исходное значение указателя — %p (в современных компиляторах отображается в шестнадцатеричной системе; изначально отсылает указатель к void *)
- int64_t — "%" PRId64
- uint64_t — "%" PRIu64
64-битные типы данных печатаем, используя только макрос стиля PRI[udixXo]64.
Почему?
На некоторых платформах 64-битные значения представлены функцией
long
, на других — long long
.Эти макросы обеспечивают оптимальные базовые характеристики формата для различных платформ.Без данных макросов формата, практически, невозможно создать форматирующую строку, подходящую одновременно для всех платформ, так как типы данных изменяются, независимо от ваших действий (и помните, задавать вышеупомянутые значения до начала печати не только не безопасно, но и нелогично).
intptr_t
— "%" PRIdPTRuintptr_t
— "%" PRIuPTRintmax_t
— "%" PRIdMAXuintmax_t
— "%" PRIuMAXОдно дополнение касательно спецификаторов формата PRI*: это макросы, причем в зависимости от конкретной платформы они расширяются до подходящих спецификаторов класса printf. А, значит, нельзя указывать:
printf("Local number: %PRIdPTR\n\n", someIntPtr);
Вместо этого, зная, что мы имеем дело с макросами, пишем:
printf("Local number: %" PRIdPTR "\n\n", someIntPtr);
Обратите внимание: % попадает в тело литерала форматирующей строки, в то время как указатель типа остается за его пределами, поскольку все соседние строки объединяются препроцессором в одном итоговом комбинированном строковом литерале.
С99 позволяет использовать описания переменных где угодно.
Мы НЕ делаем так:
void test(uint8_t input) {
uint32_t b;
if (input > 3) {
return;
}
b = input;
}
Вместо этого пишем следующим образом:
void test(uint8_t input) {
if (input > 3) {
return;
}
uint32_t b = input;
}
Предупреждение: если циклы программы ограничены, проверьте позиции инициализаторов. Иногда несистематизированные описания приводят к неожиданному снижению скорости работы. Для обычного, не ускоренного, кода (который, собственно, и используется в большинстве случаев) лучше всего делать акцент на четкости. Так, определив типы данных сразу же после завершения работы над инициализаторами, вы заметно повысите читабельность.
В С99 можно использовать циклы for для создания встроенных описаний счетчиков.
Никогда НЕ пишите:
uint32_t i;
for (i = 0; i < 10; i++)
Правильно будет:
for (uint32_t i = 0; i < 10; i++)
Одно исключение: если вы хотите сохранить значение вашего счетчика после выхода из цикла, разумеется, не стоит вставлять соответствующее описание в тело цикла.
Современные компиляторы поддерживают #pragma once.
НЕПРАВИЛЬНЫЙ вариант:
#ifndef PROJECT_HEADERNAME
#define PROJECT_HEADERNAME
.
.
.
#endif /* PROJECT_HEADERNAME */
Вместо него используем
#pragma once
#pragma once
уведомляет компилятор о необходимости запросить заголовок всего один раз, следовательно, вам больше не придется писать дополнительные строки для его защиты. Данная функция поддерживается всеми компиляторами, причем на различных платформах, и является куда более эффективным механизмом, чем ввод кода заголовка вручную.Подробное описание опции вы найдете в перечне компиляторов, поддерживающих pragma once.
Язык программирования С позволяет проводить статическую инициализацию автоматически созданных массивов.
Итак, мы не пишем:
uint32_t numbers[64];
memset(numbers, 0, sizeof(numbers));
Правильно будет:
uint32_t numbers[64] = {0};
Работая на С, вы можете осуществлять статическую инициализацию автоматически генерируемых структур.
Классическая ошибка:
struct thing {
uint64_t index;
uint32_t counter;
};
struct thing localThing;
void initThing(void) {
memset(&localThing, 0, sizeof(localThing));
}
Корректно:
struct thing {
uint64_t index;
uint32_t counter;
};
struct thing localThing = {0};
ВАЖНО: Если в вашей структуре предусмотрено внутреннее выравнивание, {0} метод не обнулит дополнительные байты, предназначенные для этих целей. Так, например, происходит, если в struct thing 4 байта отступов после counter (на 64-битной платформе), потому что структуры заполняются с шагом равным одному слову. Если вам нужно обнулить всю структуру включая неиспользованные байты отступов, указывайте
memset(&localThing, 0, sizeof(localThing))
, так как sizeof(localThing) == 16 bytes, несмотря на то, что доступно всего 8 + 4 = 12 байтов.Если потребуется повторно инициализировать ранее выделенные структуры, используйте общую нулевую структуру для последующего определения значений:
struct thing {
uint64_t index;
uint32_t counter;
};
static const struct thing localThingNull = {0};
.
.
.
struct thing localThing = {.counter = 3};
.
.
.
localThing = localThingNull;
Если вам повезло работать на C99 (или более поздних версиях), вы можете выбирать составные литералы вместо того, чтобы возиться с основной «нулевой структурой» (см. статью The New C: Compound Literals за 2001 год).
Составные литералы позволяют компилятору автоматически создавать временные анонимные структуры, а затем копировать их в соответствующее поле значения:
localThing = (struct thing){0};
В С99 появились массивы переменной длины (в С11 их можно выбирать по желанию).
Поэтому НЕ пишите так (если вы имеете дело с миниатюрным массивом или просто проводите экспресс-тестирование):
uintmax_t arrayLength = strtoumax(argv[1], NULL, 10);
void *array[];
array = malloc(sizeof(*array) * arrayLength);
/ * Не забудьте освободить (массив)после завершения работы над ним * /
Вместо этого указываем:
uintmax_t arrayLength = strtoumax(argv[1], NULL, 10);
void *array[arrayLength];
/* не нужно освобождать массив */
ВАЖНО: массивы переменной длины (как правило) создаются в стеке, как и обычные массивы. Если у вас не получается создать обычный массив на 3 миллиона элементов статически, не пытайтесь генерировать динамический массив того же объема, используя данный синтаксис. Это вам не масштабируемые автоматические списки Python/Ruby. Если задать длину массива во время запуска программы и она окажется слишком большой для вашего стека, начнется бардак (сбои в работе, проблемы с безопасностью). Массивы переменной длины идеальны для отдельных ситуаций, рассчитанных на выполнение конкретных задач, но не следует использовать их для разработки всех видов программного обеспечения. Если один раз вам понадобилось генерировать массив на 3 элемента, а другой – на 3 миллиона, вряд ли стоит прибегать к помощи массивов переменной длины.
Да, неплохо разбираться в синтаксисе VLA, зная, что он может вам пригодиться (или если нужно провести однократное экспресс-тестирование какого-то продукта). В то же время подобные затеи нередко превращаются в трагедии, когда рушатся целые программы, стоит только забыть точные параметры проверки размеров элемента или упустить из виду, что вы столкнулись с незнакомой целевой платформой, на которой не предусмотрено дополнительное стековое пространство.
ПРИМЕЧАНИЕ: Не сомневайтесь в том, что в данной ситуации
arrayLength
– оптимальный размер (то есть меньше нескольких килобайт; иногда ваш стек будет достигать максимум 4 КБ на малоизвестных платформах). Вы не сможете создавать огромные массивы (на миллионы записей), но, зная, что в вашем распоряжении ограниченное пространство, гораздо проще использовать возможности С99 VLA, а не вручную готовить запросы в динамическую память с помощью malloc
.И ЕЩЕ: в этом механизме отсутствует функция проверки данных, введенных пользователем, а потому вы можете просто уничтожить программу, выделив несоразмерный VLA. Кто-то и вовсе окрестил VLA антишаблоном, но, если соблюдать необходимые правила предосторожности, в отдельных случаях подобного рода массивы, несомненно, окажутся кстати.
C99 позволяет писать аннотации к непересекающимся параметрам указателей.
Типы параметров
Если функция работает с произвольными исходными данными и определенной длиной, не ограничивайте тип этого параметра.
Заведомо ошибочно:
void processAddBytesOverflow(uint8_t *bytes, uint32_t len) {
for (uint32_t i = 0; i < len; i++) {
bytes[0] += bytes[i];
}
}
Вместо этого используйте:
void processAddBytesOverflow(void *input, uint32_t len) {
uint8_t *bytes = input;
for (uint32_t i = 0; i < len; i++) {
bytes[0] += bytes[i];
}
}
Типы исходных данных ваших функций описывают интерфейс кода, а не манипуляции кода с параметрами. Вышеупомянутый интерфейс подразумевает процесс «принятия байтового массива и определенной длины», а потому вам не захочется ограничивать пользователей потоками
uint8_t
. Хотя, возможно, ваши клиенты захотят познакомиться поближе с такими древностями, как char *
, или чем-то более оригинальным. Объявив тип исходных данных, как void *
, и повторно назначив или еще раз сославшись на фактический тип данных, который нужен прямо в теле функции, вы обезопасите пользователей, ведь так им не придется думать о том, что происходит в вашей библиотеке.В этом примере некоторые читатели столкнулись с проблемой выравнивания, но, поскольку мы работаем только с отдельными байтовыми элементами ввода, все должно быть в порядке. Если же нужно будет сосредоточиться на более крупных величинах, действительно, придется учитывать выравнивание. Созданию кросс-платформенного кода с учетом выравнивания контента посвящена статья Unaligned Memory Access (напоминаем: это ресурс с общими обзорами не специализируется на тонкостях программирования на С под разные архитектуры, а потому все описанные примеры, желательно, применять с учетом личного опыта и имеющихся знаний).
Типы возвращаемых параметров
C99 предоставляет нам весь набор функций
<stdbool.h>
, где true равняется 1, а false — 0.В случае с удачными/неудачными возвращаемыми значениями функции должны выдавать true or false, а не возвращаемый тип
int32_t
, требующий ручного ввода 1 и 0 (или, что еще хуже, 1 и -1; как тогда разобраться: 0 – success, а 1 — failure? Или 0 – success, а -1 — failure?).Если функция произвольно изменяет исходные значения вплоть до признания их недействительными, вместо того, чтобы выдавать обработанный указатель, ваш API будет классифицировать любые двойные указатели, упомянутые в качестве исходных данных, как некорректные. Кроме того, программисты часто совершают одну и ту же ошибку, используя в коде установку, при которой «для некоторых вызовов, возвращаемое значение признает исходные данные недействительными».
Поэтому НЕ пишем так:
void *growthOptional(void *grow, size_t currentLen, size_t newLen) {
if (newLen > currentLen) {
void *newGrow = realloc(grow, newLen);
if (newGrow) {
/* размер успешно изменен */
grow = newGrow;
} else {
/* отказ в изменении размера, функция работает в свободном режиме, сигнал не проходит через ноль */
free(grow);
grow = NULL;
}
}
return grow;
}
Лучше иначе:
/* Возвращаемое значение:
* - 'true' если newLen > currentLen и стремится к увеличению
* - в данном случае 'true' не указывает на успешный результат, о нем может свидетельствовать только '*_grow'
* - 'false' если newLen <= currentLen */
bool growthOptional(void **_grow, size_t currentLen, size_t newLen) {
void *grow = *_grow;
if (newLen > currentLen) {
void *newGrow = realloc(grow, newLen);
if (newGrow) {
/* размер успешно изменен */
*_grow = newGrow;
return true;
}
/* отказ в изменении размера */
free(grow);
*_grow = NULL;
/* для данной функции,
* 'true' не указывает на успешный результат, а лишь подчеркивает стремление к увеличению */
return true;
}
return false;
}
Или, если совсем постараться, указываем следующее:
typedef enum growthResult {
GROWTH_RESULT_SUCCESS = 1,
GROWTH_RESULT_FAILURE_GROW_NOT_NECESSARY,
GROWTH_RESULT_FAILURE_ALLOCATION_FAILED
} growthResult;
growthResult growthOptional(void **_grow, size_t currentLen, size_t newLen) {
void *grow = *_grow;
if (newLen > currentLen) {
void *newGrow = realloc(grow, newLen);
if (newGrow) {
/* размер успешно изменен */
*_grow = newGrow;
return GROWTH_RESULT_SUCCESS;
}
/* отказ в изменении размера, не удаляйте данные, так как они могут понадобится для уведомления об ошибке */
return GROWTH_RESULT_FAILURE_ALLOCATION_FAILED;
}
return GROWTH_RESULT_FAILURE_GROW_NOT_NECESSARY;
}
Форматирование
Стандарт оформления кода одновременно и важен, и совершенно бесполезен.
Если для вашего проекта будет подготовлено руководство на 50 страниц с правилами оформления кода, вряд ли кто-то захочет с вами связываться. Но если созданный код нечитабелен, никто даже не подумает вам помогать.
Совет – всегда используйте автоматические форматтеры кода.
Единственный продукт, который в 2016 году позволит форматировать продукты, разработанные на языке С, — clang-format. Родные настройки clang-format на порядок выше любого другого автоматического форматтера C-кода. Более того, его разработчики постоянно трудятся над новыми функциями продукта.
Я привык использовать следующий скрипт для clang-format:
#!/usr/bin/env bash
clang-format -style="{BasedOnStyle: llvm, IndentWidth: 4, AllowShortFunctionsOnASingleLine: None, KeepEmptyLinesAtTheStartOfBlocks: false}" "$@"
Вызывайте команду так (если вы присвоили скрипту имя
cleanup-format
):matt@foo:~/repos/badcode% cleanup-format -i *.{c,h,cc,cpp,hpp,cxx}
Опция -i не сохраняет новые файлы и не создает их резервные копии, а перезаписывает существующие файлы вместе с результатами форматирования.
Если у вас много файлов, можно параллельно рекурсивно обработать все дерево исходного кода:
#!/usr/bin/env bash
# обратите внимание: clang-tidy принимает только один файл за раз, но мы можем обращаться к функции
# параллельно для непересекающихся коллекций.
find . \( -name \*.c -or -name \*.cpp -or -name \*.cc \) |xargs -n1 -P4 cleanup-tidy
# clang-format принимает несколько файлов за раз, но устанавливает ограничение на уровне 12
# что (возможно) позволит предотвратить перегрузку памяти.
find . \( -name \*.c -or -name \*.cpp -or -name \*.cc -or -name \*.h \) |xargs -n12 -P4 cleanup-format -i
Кроме того, хочется поделиться с вами и новым скриптом cleanup-tidy. Он выглядит, примерно, так:
#!/usr/bin/env bash
clang-tidy \
-fix \
-fix-errors \
-header-filter=.* \
--checks=readability-braces-around-statements,misc-macro-parentheses \
$1 \
-- -I.
clang-tidy
— инструмент рефакторинга кода. Вышеупомянутые характеристики позволяют решить две задачи:readability-braces-around-statements
– все формулировки с if/while/for заключаются в фигурные скобки;Не верится, что при программировании на С вдруг появляются одиночные команды в «дополнительных скобках» после конструкций цикла и условных операторов. Просто непозволительно писать современные коды без обязательных скобок для каждого цикла и условия. Если вы поспешите заметить, мол, «но компилятор же принимает такие команды!», уверяю – это не имеет ничего общего с читабельностью, удобством в эксплуатации или возможностью экспресс-тестирования кода. Вы же занимаетесь программированием не для того, чтобы угодить компилятору, а оставляете наследие для будущих поколений, которые смогут понять ход ваших мыслей, даже если все забудут, по какой схеме программы разрабатывались раньше.
misc-macro-parentheses
– автоматически добавляются скобки вокруг всех значений, перечисленных в теле макроса.clang-tidy
– отличный инструмент, если, конечно, работает исправно, но при создании сложного кода с ним можно и запутаться. Кроме того, clang-tidy
— не является форматом, а потому не забудьте о clang-format
после того, как расставите скобки и отформатируете макросы.Читабельность
Здесь писать, особо, нечего…
Комментарии
Следует проанализировать логические автономные части файла кода.
Структура файла
Постарайтесь ограничить файлы максимум 1000 строк (1500 строк в крайнем случае). Если проводимые тесты запрашивают исходный файл (для тестирования статических функций и т.д.), при необходимости отредактируйте его.
Мысли о разном
Никогда не используйте malloc
Привыкайте к
calloc
. С этой функцией вам не грозит снижение производительности при очистке памяти. Если вам не по душе calloc(object count, size per object)
, можете заменить ее на #define mycalloc(N) calloc(1, N)
.По данному пункту у читателей появилось несколько идей:
- сalloc сказывается на производительности, если мы имеем дело с огромными массивами данных;
- calloc сказывается на производительности программ, работающих на странных платформах (минимальные встраиваемые системы, игровые консоли, аппаратное обеспечение 30-летней давности, и т.п.);
- преобразование функции в calloc(element count, size of each element)не самое удачное решение;
- Среди явных недостатков malloc() можно отметить то, что функция не позволяет проверить переполнение размера целых переменных, что создает потенциальную угрозу безопасности;
- Использование calloc блокирует функцию инструмента valgrind, благодаря которой пользователь получает уведомления о непреднамеренном чтении/копировании данных из неинициализированной памяти, ведь применение calloc автоматически приравнивается 0;
- Перечисленные пункты – отличные дополнения. И именно поэтому важно всегда анализировать производительность, проводить тестирование производительности и регрессионное тестирование скорости для различных компиляторов, платформ, операционных систем и аппаратных средств;
- Одно из преимуществ использования calloc () в чистом виде, без упаковщика, в отличие от malloc(), calloc(), — с его помощью можно проверить переполнение размера целых переменных, так как в этом случае функция умножает все переменные для того, чтобы вычислить конечный размер кластера. Если вы работаете с небольшими объемами данных, calloc() показывает отличные результаты и в тандеме с упаковщиком. Когда же речь идет о потенциально неограниченном потоке информации, можно остановиться и на привычном вызове calloc(element count, size of each element).
Не бывает универсальных советов, но попытка сформулировать почти идеальные общие рекомендации в итоге, наверняка, закончится написанием целой книги о тонкостях конкретного языка программирования.
Если вам интересно, как с помощью
calloc()
бесплатно освободить дополнительную память, почитайте эти интересные статьи:Benchmarking fun with calloc() and zero pages (2007)
Copy-on-write in virtual memory management
В 2016 году я по-прежнему настаиваю на рекомендации всегда использовать
calloc()
для большинства привычных сценариев (в частности, для х64 целевых платформ, для данных, касающихся конкретного человека, если мы не говорим об особенностях генома). Любые отклонения от «сферы ожидаемого» грозят отчаянием, вызванным сомнениями на тему «знания предмета», но не стоит об этом.Дополнение: предварительно очищенная с помощью
calloc()
память – прекрасный результат, но это разовая акция. Если после calloc()
вы запросите realloc()
, в итоге не будет дополнительного объема чистой памяти. Перераспределенное пространство заполнит всевозможный стандартный неинициализированный контент в зависимости от возможностей ядра системы. Если вы хотите освободить место после работы с realloc()
, придется вручную запрашивать memset()
.Никогда не используйте memset
(если можно обойтись без него)
Не спешите ссылаться на
memset
(ptr, 0, len, когда можно статически задать для структуры (массива) нулевое исходное значение (или обнулить необходимые показатели, обратившись к встроенному составному литералу или к значению общей нулевой структуры).В то же время
memset()
— ваш единственный выбор, если нужно обнулить структуру, включая байты внутренних отступов (так как функция{0} распространяется только на определенные участки и игнорирует неопределенные области, отведенные под отступы).Заключение
Найти универсальную формулу для написания правильного кода, практически, невозможно. Мы имеем дело с множеством операционных систем, различным временем автономной работы программ, всевозможными библиотеками и платформами, что только увеличивает список поводов для беспокойства, даже, если мы рассматриваем случайное инвертирование битов RAM или когда наши фильтры вдруг начинают «врать».
Лучшее, что мы можем сделать — это писать простой, понятный код, в котором количество возможных сбоев и неожиданных форс-мажоров сведено к минимуму.