[Перевод] Критика статьи «Как писать на С в 2016 году»
От переводчика:
Данная публикация является третьей и последней статьей цикла, стихийно возникшего после публикации перевода статьи «How to C in 2016» в блоге Inoventica Services. Тут критикуются некоторые изложенные в оригинале тезисы и окончательно формируется законченная «картина» мнений о поднимаемых автором первой публикации вопросах и методах написания кода на С. Со второй публикацией можно ознакомиться здесь.
Мэтт (на сайте которого не указана фамилия автора, по крайней мере, насколько мне известно) опубликовал статью «Программирование на С в 2016 году», которая позже появилась на Reddit и Hacker News, именно на последнем ресурсе я ее и обнаружил.
Да, можно бесконечно «обсуждать» программирование на С, но есть аспекты, с которыми я явно несогласен. Эта критическая статья написана с позиций конструктивной дискуссии. Вполне возможно, что в отдельных случаях прав Мэтт, а заблуждаюсь я.
Я не цитирую всю публикацию Мэтта. В частности, решил опустил некоторые пункты, с которыми согласен. Начнем.
Первое правило программирования на С — не используйте его, если можно обойтись другими инструментами.
С подобным утверждением я не согласен, но это слишком широкая тема для обсуждения.
При программировании на С
сlang
по умолчанию обращается к С99, а потому дополнительные опции не требуются.
Это зависит от версии clang
: clang 3.5
по умолчанию работает с C99, clang 3.6
— с С11. Я не уверен, насколько жестко это соблюдается при использовании «из коробки».
Если вам необходимо использовать определенный стандарт для gcc или clang, не усложняйте, используйте std=cNN -pedantic.
По умолчанию
gcc-5
запрашивает-std=gnu11
, но на практике нужно указывать с99 или c11 без GNU.
Ну, разве что если вы не хотите использовать конкретные gcc расширения, которые, в принципе, вполне подходят для данных целей.
Если вы обнаружили в новом коде что-то вроде
char
,int
,short
,long
илиunsigned
, вот вам и ошибки.
Вы меня, конечно, извините, но это чушь. В частности, int — самый приемлемый тип целочисленных данных для текущей платформы. Если речь идет о быстрых беззнаковых целых, как минимум, на 16 битов, нет ничего плохого в использовании int (или можно ссылаться на опцию int_least16_t
, которая прекрасно справится с функциями того же типа, но ИМХО это куда подробнее, чем оно того стоит).
В современных программах необходимо указывать
#include
и только потом выбирать стандартные типы данных.
То, что в имени int
не прописано «std»
, не значит, будто мы имеем дело с чем-то нестандартным. Такие типы, как int
, long
и др., встроены в язык С. А typedefs, зафиксированные в
, появляются позже в качестве дополнительной информации. Это не делает их менее «стандартными», чем встроенные типы, хотя они, в некотором роде, и уступают последним.
float
— 32-битный стандарт с плавающей точкойdouble
— 64-битный стандарт с плавающей точкой
float
и double
— весьма распространенные IEEE типы для 32 и 64-битных стандартов с плавающей точкой, в частности, на современных системах, не стоит на этом зацикливаться при программировании на С. Я работал на системах, где float использовали на 64 битах.
Обратите внимание: больше никаких
char.
Обычно на языке программирования С командуchar
не только называют, но и используют неправильно.
К сожалению, слияние параметров и байтов при программировании на С неизбежно, и тут мы просто застряли. Тип char стабильно приравнивается одному байту, где «байт» — минимум, 8 битов.
Разработчики ПО то и дело употребляют команду char для обозначения «байта», даже когда выполняются беззнаковые байтовые операции. Гораздо правильнее для отдельных беззнаковых байтовых/октетных величин указывать
uint8_t
, а для последовательности беззнаковых байтовых/октетных величин выбиратьuint8_t *
.
Если подразумеваются байты, задействуйте unsigned char
. Если речь об октетах, выбирайте uint8_t
. В случае, когда CHAR_BIT > 8
, uint8_t
создать не удастся, а, значит, не получится и скомпилировать код (возможно, вам именно это и нужно). Если же мы работаем с объектами, как минимум, на 8 битов, используйте uint_least8_t
. Если под байтами имеются в виду октеты, добавляем в код что-то вроде этого:
#include
#if CHAR_BIT != 8
#error "This program assumes 8-bit bytes"
#endif
Обратите внимание: POSIX запрашивает CHAR_BIT == 8
.
на языке программирования С строковые литералы
("hello")
выглядят, какchar *
.
Нет, строковые литералы задаются типом char[]. В частности, для «hello» это char[6]. Массивы не являются указателями.
Не вздумайте писать код с использованием
unsigned
. Теперь вы знаете, как написать приличный код без несуразных условностей C с многочисленными типами данных, которые не только делают содержание нечитабельным, но и ставят под вопрос эффективность использования готового продукта.
Многим типам на C присваиваются имена, состоящие из нескольких слов. И в этом нет ничего плохого. Если вам лень печатать лишние символы, это не значит, что стоит пичкать код всевозможными сокращениями.
Кому захочется вводить unsigned long long int, если можно ограничиться простым
uint64_t
?
С одной стороны, вы можете задействовать unsigned long long, подразумевая int. В то же время, зная, что это разные вещи и что тип unsigned long long
, как минимум, 64-битный, причем в нем могут присутствовать или отсутствовать отступы. uint64_t
рассчитан ровно на 64 бита, причем без битов отступов; данный тип совсем не обязательно прописан в том или ином коде.
unsigned long long
встроенный тип на С. С ним знаком любой специалист, работающий с этим языком программирования.
Либо попробуйте uint_least64_t
, который может быть идентичным или отличаться от unsigned long long
.
Типы
куда конкретнее и точнее по смыслу, они лучше передают намерения автора, компактны — что немаловажно и для эксплуатации, и для читабельности.
Конечно, типы intN_t
и uintN_t
гораздо конкретнее. Но ведь не во всех кодах это главное. Не уточняйте то, что для вас неважно. Выбирайте uint64_t
только тогда, когда вам действительно нужно ровно 64 бита — ни больше, ни меньше.
Иногда требуются типы с точной длиной, например, когда необходимо подстроиться под определенный формат (Иногда делается акцент на порядке байтов, выравнивании элементов и тп.;
Правильный тип для указателей в данном случае —
uintptr_t
, он задается файлами.
Какая жуткая ошибка.
Начнем с мелких погрешностей: uintptr_t
задается
, а не
.
Это, если вообще говорить о конкретике. Вызов команды, где void*
невозможно преобразовать в другой целочисленный тип без потери данных, вряд ли определяет uintptr_t
(Такие случаи встречаются крайне редко, если и вовсе существуют).
Вместо:
long diff = (long)ptrOld - (long)ptrNew;
Да, так дела не делаются.
Используйте:
ptrdiff_t diff = (uintptr_t)ptrOld - (uintptr_t)ptrNew;
Но ведь этот вариант ничуть не лучше.
Если хотите подчеркнуть разницу типов, пишите:
ptrdiff_t diff = ptrOld - ptrNew;
Если нужно сделать акцент на байтах, выбирайте что-то вроде:
ptrdiff_t diff = (char*)ptrOld - (char*)ptrNew;
Если ptrOld
и ptrNew
не указывают на необходимые параметры, или просто перескакивают с конца объекта, сложно будет проследить, как указатель вызывает команду вычитания данных. Переход на uintptr_t
гарантирует хотя бы относительный результат, правда, его вряд ли можно назвать очень полезным. Проводить сравнение или другие арифметические действия с указателями допустимо только при написании кода для систем высокого уровня, в противном случае важно, чтобы исследуемые указатели ссылались на конец определенного объекта или перескакивали с него (Исключение: == и != прекрасно работают для указателей, ссылающихся на разные объекты).
В подобных ситуациях рационально обращаться к intptr_t — целочисленному типу данных, соответствующему величинам, равным слову, на вашей платформе.
А вот и нет. Понятие «равный слову» весьма абстрактно. intptr_t
знаковый целочисленный тип, который успешно конвертирует void*
в intptr_t
и обратно без потери данных. Причем это может быть значение, превышающее void*
.
На 32-битных платформах
intptr_t
трансформируется вint32_t
.
Бывает, но не всегда.
На 64-битных платформах
intptr_t
приобретает видint64_t
.
И снова, вполне вероятно, но не обязательно.
По сути,
size_t
— что-то вроде «целой величины, способной хранить огромные индексы массива.
Неееет.
а, значит, ему под силу фиксировать внушительные показатели смещения в создаваемой программе.
Да, этот тип данных позволяет сохранять информацию о размере самого крупного объекта, задействованного при запуске программы (существует также мнение, будто это тоже необязательно, но практики ради можно считать, что именно так все и происходит). Он может фиксировать основное смещение памяти, если все смещения произведены в рамках одного объекта.
В любом случае на современных платформах
size_t
обладает, практически, теми же характеристиками, что иuintptr_t
, а потому на 32-битных версияхsize_t
трансформируется вuint32_t
, а на 64-битных — вuint64_t
.
Скорее всего, но не обязательно.
А если конкретнее, size_t
может использоваться для сохранения размера любого отдельного объекта, в то время как uintptr_t
задает любое значение указателя, а, соответственно, с их помощью вы больше не перепутаете адреса байтов различных объектов. Большинство современных систем работает с неделимыми адресными строками, и поэтому, теоретически, максимальный размер объекта равен общему объему памяти. Стандарты программирования на С требуют строгого соблюдения данного требования. Так, например, вы можете столкнуться с ситуацией, когда на 64-битной системе объекты не превышают 32 бита.
Выделяя слово «современные», мы автоматически опускаем обе старые альтернативы (вроде x86, на которой использовали сегментированную адресацию с указателями near и far), и не касаемся возможных будущих продуктов, которые также могут предусматривать совместимость со стандартами С, хотя и выходить за рамки определения «современных».
Не ссылайтесь на типы данных во время работы. Всегда используйте соответствующие указатели типа.
Это один из вариантов, но не единственное удачное решение (И, наверняка, вы согласитесь, что нужно все же упоминать void* для »%р»).
Исходное значение указателя — %p (в современных компиляторах отображается в шестнадцатеричной системе; изначально отсылает указатель к
void *
)
Отличный совет — только выходной формат задается параметрами запуска. Обычно это шестнадцатеричное значение, но не думайте, что другого не дано.
printf("Local number: %" PRIdPTR "\n\n", someIntPtr);
Имя someIntPtr
подразумевает тип int*
, на самом деле задает тип intptr_t
.
Тут могут быть вариации на тему, а, значит, вам не нужно заучивать бесконечные комбинации имен макросов:
some_signed_type n;
some_unsigned_type u;
printf("n = %jd, u = %ju\n", (intmax_t)n, (uintmax_t)u);
intmax_t
и uintmax_t
, как правило, 64-битные. Их преобразования гораздо экономичнее физических I/O.
Обратите внимание: % попадает в тело литерала форматирующей строки, в то время как указатель типа остается за его пределами.
Все это части форматирующей строки. Макросы задаются как строковые литералы, объединенные с соседними строковыми литералами.
Современные компиляторы поддерживают
#pragma once
Но никто не говорит, что вы обязаны использовать данную директиву. Даже в инструкции процессоров не озвучиваются подобные рекомендации. И в разделе «Заголовки с Once» ни слова о #pragma once; зато описывается #ifndef
. В следующем разделе «Альтернативы упаковщика #ifndef» мелькнула #pragma once, но и в этом случае всего лишь отмечено, что это не портативная опция.
Данная функция поддерживается всеми компиляторами, причем на различных платформах, и является куда более эффективным механизмом, чем ввод защитного кода заголовка вручную.
И кто это дает такие рекомендации? Директива #ifndef
, может, и неидеальна, зато надежна и портативна.
ВАЖНО: Если в вашей структуре предусмотрены внутренние отступы, {0} метод не обнулит дополнительные байты, предназначенные для этих целей. Так, например, происходит, если в struct thing 4 байта отступов после
counter
(на 64-битной платформе), потому что структуры заполняются с шагом равным одному слову. Если вам нужно обнулить всю структуру включая неиспользованные байты отступов, указывайтеmemset(&localThing, 0, sizeof(localThing))
, так какsizeof(localThing) == 16 bytes
, несмотря на то, что доступно всего 8 + 4 = 12 байтов.
Задача усложняется. Обычно нет никаких причин уделять особое внимание байтам отступов. Если вам все же захотелось посвятить им свое драгоценное время, используйте memset
для их обнуления. Хотя отмечу, что очистка структур с помощью memset
, даже с учетом того, что целым элементам, действительно, будет присвоено значение нуля, не гарантирует того же эффекта для типов с плавающей точкой или указателей — должны, соответственно, равняться 0.0 и NULL
(хотя на большинстве систем функция отлично работает).
В С99 появились массивы переменной длины
Нет, в C99 не предусмотрены инициализаторы для VLA (массивы переменной длины). Но Мэтт, по сути, и не пишет об инициализаторах VLA, упоминая только сами VLA.
Массивы переменной длины — явление противоречивое. В отличие от malloc, они не предполагают обнаружение ошибок при распределении ресурсов. Так что, если вам нужно выделить N количество байтов данных, вам понадобится:
{
unsigned char *buf = malloc(N);
if (buf == NULL) { /* allocation failed */ }
/* ... */
free(buf);
}
по крайней мере, в общем и целом, это безопаснее, чем:
{
unsigned char buf[N];
/* ... */
}
Да, ошибки при использовании VLA чреваты серьезными проблемами. Но ведь то же самое можно сказать, практически, о каждой функции на любом языке программирования.
Причем со старыми массивами фиксированной длины возникали аналогичные вопросы. Пока вы проверяете размер перед созданием массива, VLA с переменным N так же безобиден, как массив фиксированной длины того же размера. Как правило, для описания массивов фиксированной длины выбирают значение, превышающее количество предполагаемых элементов, поскольку его часть необходима для хранения фактических данных. С VLA можно выделить ровно столько места, сколько требуется компонентам. И здесь я согласен с рекомендацией Мэтта.
Кроме одного аспекта: в С11 можно выбирать VLA по желанию. Сомневаюсь, что большинство компиляторов C11, на самом деле, станут воспринимать массивы переменной длины, как опциональные, разве что в случае небольших встроенных систем. Правда, об этой особенности стоит помнить, если вы планируете написать максимально переносимый код.
Если функция работает с *произвольными** исходными данными и определенной длиной, не ограничивайте тип этого параметра. *
Заведомо ОШИБОЧНО:
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];
}
}
Согласен, void*
идеальный тип для фиксирования параметров произвольного фрагмента памяти. Взять хотя бы функции mem*
в стандартной библиотеке (Но len должен быть size_t
, а не uint32_t
).
Объявив тип исходных данных, как void *, и повторно назначив или еще раз сославшись на фактический тип данных, который нужен прямо в теле функции, вы обезопасите пользователей, ведь так им не придется думать о том, что происходит в вашей библиотеке.
Маленькое замечание: это не прописано в функции Мэтта. Здесь мы видим неявное преобразование void*
в uint8_t*
.
В этом примере некоторые читатели столкнулись с проблемой выравнивания.
И они ошиблись. Если мы работаем с определенным фрагментом памяти, как с последовательностью байтов, это всегда безопасно.
C99 предоставляет нам весь набор функций
, где
true
равняется 1, аfalse - 0
.
Да, а кроме того, так можно задавать bool
, используемый в качестве псевдонима для встроенного типа _Bool
.
В случае с удачными/неудачными возвращаемыми значениями функции должны выдавать
true
orfalse
, а не возвращаемый типint32_t
, требующий ручного ввода 1 и 0 (или, что еще хуже, 1 и -1; как тогда разобраться: 0 —success
, а 1 —failure?
Или 0 —success
, а -1 —failure?
)).
Существует широко распространенный алгоритм, в частности, на системах вроде Unix, когда в случае успеха функция выдает 0, а при отказе — какое-нибудь ненулевое значение (часто -1). Во многих ситуациях вариативные ненулевые результаты указывают на различные виды ошибок. Добавляя новые функции в готовые интерфейсы, важно следовать вышеупомянутому стандарту (0 эквивалентен успеху, поскольку, в целом, есть только один вариант эффективной работы функции, а вот погрешностей в ней может быть много).
Функция, созданная для анализа тех или иных условий, должна выдавать true
или false
. Только не путайте их с удачными/неудачными исходами запуска кода.
Функции bool
обязательно присваивается имя в виде утверждения. По-английски это будет формулировка, отвечающая на вопрос да/нет. Например, is_foo()
и has_widget()
.Функция, рассчитанная на конкретное действие, в случае с которым для вас важно знать, насколько успешно его можно выполнить, вероятно, будет задаваться другим утверждением. В некоторых языках разумно прибегать к добавлению/вычитанию исключений. На C приходится следовать определенным негласным правилам, в том числе, задавая нулевое значение для положительного результата функции.
Единственный продукт, который в 2016 году позволит форматировать продукты, разработанные на языке С, — clang-format. Родные настройки clang-format на порядок выше любого другого автоматического форматтера C-кода.
Сам я не использовал clang-format. Мне только предстоит с ним познакомиться.
Но хотелось бы озвучить несколько принципиальных моментов касательно форматирования С-кода:
- Открытые скобки ставим в конце строки;
- Вместо tab используем пробелы;
- 4-колонки в одном уровне;
- Фигурные скобки наше все (за исключением отдельных случаев, когда в целях повышения читабельности проще перечислять задачи прямо в строчку).
- Следуйте инструкциям проекта, над которым работаете.
Я редко обращаюсь к инструментам автоматического форматирования. Может, зря?
Никогда не используйте
malloc
Привыкайте кcalloc
.
Вот еще. Попытка обнулить все биты выделенной памяти сводится к весьма произвольному процессу, и, как правило, это не лучшая идея. Если код написан правильно, вы не сможете вызвать тот или иной объект, предварительно не присвоив ему соответствующее значение. Используя calloc
, вы столкнетесь с тем, что любой баг в коде будет приравниваться к нулю, а, значит, легко будет перепутать системную ошибку с ненужными данными. Разве это похоже на усовершенствование кода?
Обнуление памяти часто приводит к тому, что ошибка в программном коде запускает последовательные алгоритмы; по определению это нельзя назвать правильным ходом запуска. А ведь последовательные погрешности отслеживать гораздо сложнее.
Да, если бы код писался без ошибок. Но если при создании кода вы придерживаетесь защитной стратегии, возможно, стоит присвоить выделенной памяти определенное значение из разряда недействительных.
С другой стороны, если обнуление всех битов решает поставленные задачи, можно попробовать задействовать calloc
.