Pull to refresh

Как я пишу на C по состоянию на конец 2023 года

Level of difficultyMedium
Reading time9 min
Views27K
Original author: Chris Wellons
Этот год выдался переломным для моих навыков по программированию на 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.

Параметры и функции


Никаких 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, все из которых зарезервированы. Вот несколько примеров:

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 идет Черная пятница в издательстве «Питер»
Tags:
Hubs:
Total votes 39: ↑27 and ↓12+25
Comments91

Articles

Information

Website
piter.com
Registered
Founded
Employees
201–500 employees
Location
Россия