[Перевод] Как я пишу на C по состоянию на конец 2023 года
Этот год выдался переломным для моих навыков по программированию на C. Можно сказать, что я пережил слом парадигмы, что побудило меня пересмотреть привычки и весь стиль программирования. Это была крупнейшая метаморфоза моего личного профессионального стиля за долгие годы, так что я решил написать этот пост в качестве «мгновенного снимка» моих нынешних суждений и профессионального существования. Эти перемены во многом пошли на пользу моей продуктивности и организованности, поэтому, при всей субъективности того, что я скажу, в посте наверняка будут описаны и вполне объективные вещи. Я не утверждаю, что на С нужно писать именно так, как рассказано ниже, а я сам, выступая контрибьютором некоторого проекта, придерживаюсь того стиля, который там заведен. Но описанные ниже приёмы, как оказалось, очень пригодились мне при работе.
Примитивные типы
Начнём с основ. Я подбираю краткие имена для примитивных типов. Код получается даже ещё более ясным, чем я ожидал, ревью моего кода, как говорят, тоже делать очень приятно. Эти имена то и дело попадаются в программе, поэтому в данном случае краткость только на пользу. Кроме того, теперь я обхожусь без суффиксов _t — оказалось, они мозолят глаза гораздо сильнее, чем я мог бы подумать.
typedef uint8_t u8;
typedef char16_t c16;
typedef int32_t b32;
typedef int32_t i32;
typedef uint32_t u32;
typedef uint64_t u64;
typedef float f32;
typedef double f64;
typedef uintptr_t uptr;
typedef char byte;
typedef ptrdiff_t size;
typedef size_t usize;
Некоторые предпочитают добавлять к знаковым типам префикс s. Я предпочитаю i, плюс, как видите, у меня есть другие варианты применения s. При работе с размерами вариант isize был бы более единообразным, причём, тогда бы не поглощался идентификатор. Но знаковые значения размеров — это образ жизни, мне они нужнее привилегий. usize — это ниша, предназначенная, в основном, для взаимодействия с внешними интерфейсами там, где это может быть важно.
b32 — это »32-разрядное булево значение», причём, понятно, зачем оно требуется. Можно было бы воспользоваться _Bool, но я предпочитаю придерживаться естественного размера слова, не вдаваясь в его странную семантику. Начинающему читателю может показаться, что я просто «растрачиваю память», когда пользуюсь 32-разрядными булевыми значениями, но на практике это просто не так. Оно находится или в регистре (возвращаемое значение, локальная переменная), либо всё равно будет увеличиваться до нужного размера при помощи заполнителя (поле структуры). Когда это действительно важно, я упаковываю булевы значения в переменную flags, а 1-байтовое булево значение редко бывает важным.
Притом, что кодировка UTF-16 может показаться нишевой, на самом деле это необходимое зло, когда приходится работать с Win32. Поэтому c16 (»16-разрядный символ») так часто появляется в коде. За основу для него я мог бы взять uint16_t, но, помещая имя char16_t в соответствующую «иерархию типов», я сообщаю отладчикам (в частности, GDB), что в этих переменных содержатся символьные данные. Официально в Win32 используется тип wchar_t, но при работе с UTF-16 мне нравится недвусмысленность.
Вариант u8 — для восьмёрок, как правило, это данные в кодировке UTF-8. Он отличается от byte, представляющего сырой фрагмент памяти, и представляет собой особый псевдонимный тип. Теоретически, это могут быть разные типы с разной семантикой, хотя, я и не знаю, существуют ли (пока?) какие-либо реализации, в которых такое практикуется. Пока речь только о намерениях.
Что насчёт систем, в которых не поддерживаются типы с фиксированной шириной? Это академический вопрос, и на его обсуждение было впустую потрачено слишком много времени. В частности, не стоило уделять столько внимания выделению типа int_fast32_t и другой подобной бессмыслице. Сейчас практически не существует софта, который бы корректно работал в таких системах. Уверен, что никто этот софт не тестировал — поэтому, представляется, что его качество, в сущности, никого всё равно не волнует.
Я не собираюсь использовать эти имена в отдельности, например, в сниппетах кода (за пределами этой статьи). В противном случае потребовалось бы, чтобы из typedefs читатель мог узнать подробный контекст. Это не стоит дополнительных объяснений. Даже в самых свежих статьях я использовал ptrdiff_t вместо size.
Макросы
Вот мой «стандартный» набор макросов:
#define sizeof(x) (size)sizeof(x)
#define alignof(x) (size)_Alignof(x)
#define countof(a) (sizeof(a) / sizeof(*(a)))
#define lengthof(s) (countof(s) - 1)
Притом, что я по-прежнему предпочитаю писать все константы капслоком (ALL_CAPS), я взял на вооружение нижний регистр для тех макросов, что подобны функциям — поскольку в таком виде их удобнее читать. Они не доставляют таких проблем с пространствами имён, как определения других макросов: у меня не может быть макроса под названием new (), а к тому же переменных и послей под названием new, ведь внешне они не похожи на вызовы функций.
Вот какой вид примет мой любимый макрос assert при работе с GCC и Clang:
#define assert(c) while (!(c)) __builtin_unreachable()
Кроме типичных достоинств у него есть и некоторые другие полезные свойства:
• В нём не требуется отдельных определений для отладочных и релизных сборок. Напротив, он контролируется благодаря участию «чистильщика неопределённых поведений» (UBSan), а неопределённое поведение в описываемых состояниях может либо присутствовать, либо отсутствовать. Это определяется, в частности, при помощи фаззинг-тестирования.
• libubsan предоставляет диагностическую распечатку с указанием файла и номера строки.
• В релизных сборках эта информация превращается в действенную подсказку по оптимизации.
Чтобы активировать утверждения в релизных сборках, переведите UBSan в режим прерываний; это делается командой -fsanitize-trap. Затем включите, как минимум, -fsanitize=unreachable. Теоретически, то же самое должно быть достижимо и при помощи -funreachable-traps, но на момент написания данной статьи эта функция не работает, так как повреждена в нескольких последних релизах GCC.
Чтобы активировать утверждения в релизных сборках, переведите UBSan в режим прерываний; это делается командой -fsanitize-trap. Затем включите, как минимум, -fsanitize=unreachable. Теоретически, то же самое должно быть достижимо и при помощи -funreachable-traps, но на момент написания данной статьи эта функция не работает, так как повреждена в нескольких последних релизах GCC.
Параметры и функции
Никаких const. Такая константа не играет никакой практической роли при оптимизации, и я не могу припомнить ни одного случая, в котором с её помощью удалось или удалось бы отловить ошибку. Я некоторое время придерживал const в документации по прототипу, но, поразмыслив, решил, что качественных названий параметров вполне достаточно. Отказавшись от const, я заметил, что стал работать гораздо продуктивнее, так как могу не забивать голову лишней информацией, а визуально код также стал выглядеть гораздо чище. Теперь думаю, что включение const в C было ошибкой, которая дорого нам обошлась.
(Одна небольшая оговорка: const мне по-прежнему нравится в качестве подсказки о том, куда ставить статические таблицы в областях памяти, предназначенных только для чтения. Если потребуется, я выброшу const. Важность её теперь минимальная.)
Литерал 0 — для нулевых указателей. Коротко и ясно. Для меня это не новость, в таком стиле я пишу последние лет 7. Теоретически здесь возможны некоторые пограничные случаи, в которых такая практика может приводить к дефектам, и много чернил было пролито на эту тему, но после нескольких сотен тысяч строк кода я такого пограничного эпизода ещё не встретил.
Пользуйтесь restrict при необходимости, но лучше организуйте код так, чтобы такой необходимости не возникало. Например, не выводите параметры в «out» в виде циклов, либо вообще не используйте исходящих параметров (сейчас расскажу об этом подробнее). По поводу inline я не волнуюсь, поскольку, так или иначе, компилирую всё как один трансляционный блок.
Давайте определения типов (typedef) для всех структур. В своё время я стеснялся так делать, но, если полностью убрать из кода ключевое слово struct, то код проще читать. Если это рекурсивная структура, то ставьте предварительное объявление прямо над ней, чтобы в таких полях можно было использовать краткое имя:
typedef struct map map;
struct map {
map *child[4];
// ...
};
Все функции кроме входных точек объявляйте как static. Опять же, когда всё компилируется как один блок для трансляции, нет причин поступать иначе. Вероятно, это ошибка, что в C вариант static не действует по умолчанию, но здесь я утверждать не берусь. Когда мы немного разгребём код, пользуясь краткими вариантами типов, уберём все const, struct, т.д., функция будет отлично умещаться в одну строку с собственным возвращаемым типом. Я обычно разбиваю их, чтобы название функции начиналось с отдельной строки, но необходимости в этом больше нет.
В том коде, что я пишу, я иногда стараюсь убирать static ради простоты, а ещё потому, что вне полного контекста программы это слово, как правило, не имеет значения. Но ниже я всё-таки буду им пользоваться, чтобы акцентировать описываемый стиль.
В течение некоторого времени я писал все имена типов с заглавной буквы, что, фактически, позволяло выносить их в отдельное пространство имён от переменных и функций, но, в конце концов, перестал так делать. Может быть, вернусь к этой идее в будущем.
Строки
Одно из самых продуктивных изменений, удавшихся мне в этом году, заключается в следующем: я смог полностью отказаться от строк, завершаемых нулём — они кажутся мне ещё одной ужасной ошибкой природы — и взять на вооружение вот такой базовый строковый тип:
#define s8(s) (s8){(u8 *)s, lengthof(s)}
typedef struct {
u8 *data;
size len;
} s8;
Я использовал несколько названий для него, но это — моё любимое. Здесь s означает строку, а 8 — кодировку UTF-8 или u8. Макрос s8 (иногда именуемый просто S) обёртывает строковый литерал C, делая из него строку s8. Строка s8 обрбатывается как толстый указатель, передаваемый и возвращаемый копированием. s8 отлично подходит в в качестве префикса функции — в отличие от str, все из которых зарезервированы. Вот несколько примеров:
качестве префикса функции — в отличие от str, все из которых зарезервированы. Вот несколько примеров:
static s8 s8span(u8 *, u8 *);
static b32 s8equals(s8, s8);
static size s8compare(s8, s8);
static u64 s8hash(s8);
static s8 s8trim(s8);
static s8 s8clone(s8, arena *);
А затем в комбинации с макросом:
if (s8equals(tagname, s8("body"))) {
// ...
}
Здесь есть соблазн воспользоваться элементом гибкого массива, чтобы в одном выделенном участке памяти уложить сразу и массив, и размер. Пробовал. Такая конструкция получается настолько негибкой, что девальвируются любые её возможные достоинства. Давайте, например, рассмотрим, как создавать такую строку из литерала, и как она будет использоваться.
Бывало, мне казалось: «программа настолько проста, что мне не понадобится строковый тип для таких данных». В этом я почти всегда ошибался. Имея строковый тип, я могу яснее мыслить, а это помогает проще структурировать программы. (В C++ аналогичные возможности появились всего несколько лет назад, они реализованы при помощи std: string_view и std: span.)
Для этой структуры данных есть аналог в UTF-16, s16:
#define s16(s) (s16){u##s, lengthof(u##s)}
typedef struct {
c16 *data;
size len;
} s16;
Не то чтобы мне так нравилось приклеивать u к литералу в макросе, лучше я буду подробно выписывать всё это в виде строкового литерала.
Ещё структуры
Ещё одно изменение — я приучился возвращать структуры, а не исходящие параметры. Фактически, это возврат множественных значений, пусть и без деструктуризации. С организационной точки зрения — большая перемена. Например, эта функция возвращает два значения: результат синтаксического разбора и состояние:
typedef struct {
i32 value;
b32 ok;
} i32parsed;
static i32parsed i32parse(s8);
Вас беспокоит «лишнее копирование»? Не волнуйтесь, ведь, благодаря соглашениям об именовании, здесь получается скрытый исходящий параметр, квалифицированный как restrict. Кроме того, в некоторых случаях он встраивается, так что все издержки на возвращаемые значения всё равно оказываются не важны. При возврате в таком стиле меня не так тянет использовать внутриполосные сигналы, в частности, обозначать ошибки специальными null-возвратами, что достаточно туманно.
Кроме того, так я выработал привычку указывать в самом верху функции возвращаемое значение, которое инициализируется в значении «ноль». Например, ok сразу означает false. Затем я использую его со всеми операторами return. В случае ошибки я могу обойтись без проблем, сразу же вернувшись. Если операция выполнится успешно, то перед возвратом ok устанавливается в true.
static i32parsed i32parse(s8 s)
{
i32parsed r = {0};
for (size i = 0; i < s.len; i++) {
u8 digit = s.data[i] - '0';
// ...
if (overflow) {
return r;
}
r.value = r.value*10 + digit;
}
r.ok = 1;
return r;
}
Кроме статических данных я также отказался от всех инициализаторов кроме традиционного инициализатора нуля. (Важные исключения: макросы s8 и s16.). Здесь речь и о выделенных инициализаторах. Вместо этого я теперь пользуюсь инициализацией с присваиванием. Например, вот «конструктор» с буферизованным выводом:
typedef struct {
u8 *buf;
i32 len;
i32 cap;
i32 fd;
b32 err;
} u8buf;
static u8buf newu8buf(arena *perm, i32 cap, i32 fd)
{
u8buf r = {0};
r.buf = new(perm, u8, cap);
r.cap = cap;
r.fd = fd;
return r;
}
Мне нравится, как это читается, и с когнитивной точки зрения этот код тоже легче. Эти присваивания разделены точками, поэтому сразу видно, в каком порядке они идут. Здесь порядок не важен, а в других случаях бывает важен:
example e = {
.name = randname(&rng),
.age = randage(&rng),
.seat = randseat(&rng),
};
Из одного зерна получаем здесь 6 возможных значений для e. Мне не нравится задумываться обо всех этих возможностях.
И напоследок
Старайтесь писать __attribute, а не __attribute__. Суффикс __ избыточен и не нужен.
__attribute((malloc, alloc_size(2, 4)))
При системном программировании под Win32, где, как правило, требуется сравнительно немного объявлений и определений, лучше не включайте windows.h, а выписывайте прототипы вручную, пользуясь для этого собственными типами. Так сокращается время сборки, пространства имён становятся чище, а интерфейсы в программе — аккуратнее custom (больше никаких DWORD/BOOL/ULONG_PTR, только u32/b32/uptr).
#define W32(r) __declspec(dllimport) r __stdcall
W32(void) ExitProcess(u32);
W32(i32) GetStdHandle(u32);
W32(byte *) VirtualAlloc(byte *, usize, u32, u32);
W32(b32) WriteConsoleA(uptr, u8 *, u32, u32 *, void *);
W32(b32) WriteConsoleW(uptr, c16 *, u32, u32 *, void *);
При работе со встроенным ассемблерным кодом внешние фигурные скобки можно приравнивать к круглым. Перед открывающей фигурной скобкой ставьте пробел, точно как в случае с if, а каждое из последующих ограничений начинайте с двоеточия.
static u64 rdtscp(void)
{
u32 hi, lo;
asm volatile (
"rdtscp"
: "=d"(hi), "=a"(lo)
:
: "cx", "memory"
);
return (u64)hi<<32 | lo;
Разумеется, этим стилистические рекомендации не ограничиваются, но именно к этим я пришёл в уходящем году. Если хотите посмотреть большинство из вышеупомянутых вещей на практике в небольшой программе — взгляните на wordhist.c у меня в Гитхабе.
p/s идет Черная пятница в издательстве «Питер»