Этот год выдался переломным для моих навыков по программированию на 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 идет Черная пятница в издательстве «Питер»

Комментарии (85)


  1. Kelbon
    21.11.2023 13:43
    +24

    Теперь думаю, что включение const в C было ошибкой, которая дорого нам обошлась.

    То есть вы не видите разницу между


    void f(const int*);

    и

    void f(int*);


    1. SIISII
      21.11.2023 13:43
      +18

      А есть ещё статические структуры данных -- например, таблицы коэффициентов или ещё что-нибудь в этом роде. На ПК особой разницы нет, объявлены они как просто переменные или как const (ну, не считая того, что без const компилятор не сможет обнаружить ошибочную запись в них -- но будем считать, что проблемы отладки нас не волнуют). Но для всяких там микроконтроллеров разница будет очень существенной: если компилятор видит const, он эти данные положит во флэш-память (в ПЗУ), и они будут готовы к использованию мгновенно, ну а если он его не видит, он будет выделять место в ОЗУ и тем или иным способом заносить туда нужные значения уже во время выполнения программы. В результате программа займёт больше места и будет медленнее работать -- и это не всегда мелочь. Скажем, у популярных "ардуинок" на Атмеге-324 (если память не изменяет) имеется 32 Кбайта флэш-памяти и всего 2 Кбайта ОЗУ.


      1. vk6677
        21.11.2023 13:43
        +4

        Справедливости ради, в той же ОС "Linux" константы (например, строковые литералы) размещаются в защищенных от записи страницах памяти. Обращение к ним может привести к ошибкам сегментации. Как я понимаю, это позволяет при запуске нескольких экземпляров одного процесса использовать общие данные без риска их повреждения.


        1. includedlibrary
          21.11.2023 13:43
          +4

          Всё равно, пишете вы так char *str = "Hello World"; или так const char *str = "Hello World"; компилятор положит строки в .rodata (при условии, что вы линкеру не сказали этого не делать), а вот если писать так char str[] = "Hello World";, то буфер будет выделен на стеке и строка будет помещаться в него при вызове функции.


          1. vk6677
            21.11.2023 13:43

            Вы правы. Как раз const char *str позволит выловить ошибку на этапе компиляции, а не выполнения. Хотя скорее всего будет предупреждение, что char *str писать можно, но не нужно.

            Массив символов скорее всего будет не в стеке, а в области инициализированных данных. А указатель на массив может быть как глобальной переменной. Так и в стеке локальной области видимости.


            1. includedlibrary
              21.11.2023 13:43

              Если объявить char str[] = "Hello World"; внутри функции, то в стеке. Таки да, будет предупреждение, но всегда можно использовать -Werror.


              1. vk6677
                21.11.2023 13:43

                Извините, но как оно попадёт в стек как не из области инициализированных данных секции .data ?

                Я обязательно проверю это. Моё предположение, что в стеке функции будет указатель на начало массива, инициализированный числом из секции .data. Не думаю что будет копирование данных.


                1. includedlibrary
                  21.11.2023 13:43

                  На amd64 с помощью mov. То есть данные хранятся в инструкциях. Вставьте этот пример в godbolt, увидете, что строка преобразуется в число, это число помещается в регистр, а регистр уже помещается в стек. Я с флагом -O3 смотрел, если что.

                  #include <stdio.h>
                  
                  void print_hello(void) {
                      char str[] = "Hello World\n";
                      fwrite(str, 1, sizeof(str) - 1, stdout);
                  }
                  
                  int main(void) {
                      print_hello();
                  }


                1. includedlibrary
                  21.11.2023 13:43

                  Да, я забыл сказать, что имею в виду amd64. На других архитектурах, например на MIPS, строка может помещаться в секцию .data. Но копирование таки происходит, что с const, что без const даже в таких случаях.


                  1. vk6677
                    21.11.2023 13:43

                    Тоже посмотрел. Идёт копирование. Причём в секции кода.


                    1. vk6677
                      21.11.2023 13:43

                      И ещё добавлю. Для малых масивов идёт заполнение стека прямо из кода, но для длинной строки (набил пару сотен символов) - вызывается memcpy.


                    1. vk6677
                      21.11.2023 13:43
                      +1

                      Интересная ситуация получается.

                      1. Для коротких строк стек заполняется инструкциями.

                      2. Если строка увеличивается, то она переезжает в секцию инициализированных данных, но используются инструкции копирования (clang вызывает memcpy, а gcc использует команды копирования с префиксом rep).

                      3. Модификатор static независимо от длины строки использует секцию инициализированных данных и прямое обращение без копирования.

                      Задумался, возможно стоит для строковых литералов, инициализируемых внутри функции всегда использовать static...


                      1. includedlibrary
                        21.11.2023 13:43

                        static char str[] = ""; не использует стек, но данные размещаются не в read only секции, потому что это массив, а не указатель на строку, а массивы можно изменять. static char *str = ""; создаст указатель, который будет находится в .data и указывать на неизменяемую строку, но сам указатель где-то в тексте программы можно будет поменять. Так что использовать static для строк всегда не имеет особого смысла.


                      1. SIISII
                        21.11.2023 13:43

                        Переменная static, объявленная внутри функции, от переменной (хоть static, хоть не-static), объявленной на глобальном уровне, отличается только областью видимости: только изнутри внутри функции. Однако память для таких переменных выделяется, как для глобальных, т.е. не в стеке, а в обычной области данных, создаваемой в процессе компиляции. Это связано с временем жизни переменной: static должна сохраняться всё время жизни программы, а не только до конца выполнения функции (или даже до выхода из блока внутри функции, где она объявлена). Так что любые, по сути, константы достаточно крупного размера стоит делать именно статиками -- чтобы они создавались один раз (при компиляции или, возможно, в момент запуска программы), а не при каждом выполнении функции.


                      1. includedlibrary
                        21.11.2023 13:43
                        +1

                        Так что любые, по сути, константы достаточно крупного размера стоит делать именно статиками

                        Не совсем так. Любая глобальная переменная, хоть static, хоть не static, будет жить всё время исполнения программы. Однако static переменная доступная только в том .c файле, в котором объявлена, а не static доступна из любого места. Надо учитывать этот момент.

                        UPD:

                        Разница между

                        void somefunc(void) {
                            char *str = "очень длинная строка";
                            // как-то используем str
                        }

                        и

                        static char *str = "очень длинная строка";
                        
                        void somefunc(void) {
                            // как-то используем str
                        }

                        Только в том, что str во втором случае будет доступен и другим функциям. То есть очень большую строку можно поместить и в тело функции, если она больше нигде не используется, так как она всё равно будет храниться в .rodata. А static её имеет смысл объявлять только тогда, когда ещё каким-то другим функциям нужен к ней доступ


                      1. SIISII
                        21.11.2023 13:43
                        +1

                        Это да, но в данном случае я сравнивал переменные, объявленные внутри функции (где static определяет время жизни), с переменными вне функции (где время жизни от наличия или отсутствия static не зависит) -- ведь обсуждение идёт про создание и инициализацию, что зависит от времени жизни, но не от области видимости.


                      1. includedlibrary
                        21.11.2023 13:43
                        +1

                        Я уточнял конкретный тезис

                        Так что любые, по сути, константы достаточно крупного размера стоит делать именно статиками

                        С остальным не спорю


                      1. vk6677
                        21.11.2023 13:43

                        В принципе всё логично. Если мы хотим при каждом вызове функции иметь её первоначальное состояние, по придётся каждый раз копировать. Хоть из секции данных, хоть инструкциями кода. Избежать копирования невозможно.


                      1. SIISII
                        21.11.2023 13:43
                        +2

                        Если сами исходные данные в процессе выполнения функции меняются -- да. Но если они являются, по сути, константами (те же строковые литералы) -- нет, и их копирование является излишним.


                      1. vk6677
                        21.11.2023 13:43

                        Много статей читал, какие эффективные сейчас компиляторы. Думал, что если строковой литерал не меняется в функции, то будет исключено лишнее копирование. Заблуждался. Буду знать, и сам за счёт ключевых слов заботиться о исключении копирования, где это не требуется.


                      1. includedlibrary
                        21.11.2023 13:43
                        +2

                        Копирования не будет, если объявлять указатель, а не массив.

                        так char str[] = "строка"; будет копирование, а так char *str = "строка"; - нет. Так и должно быть, ведь для инициализации массива на стеке нужно копирование.


                      1. vk6677
                        21.11.2023 13:43

                        Про это я знаю. С этого начиналась ветка. Про то что при объявлении литерала как неизменного массива на стекле будет всегда происходить копирование - не задумывался. Теперь буду это учитывать.


                      1. includedlibrary
                        21.11.2023 13:43
                        +1

                        Это я к тому, что умный компилятор всё правильно в данном случае делает. Ему сказали сделать массив, он сделал. Если массив при этом не изменяется, то неясно, зачем его вообще объявили, когда нужно было использовать строковый литерал.


                      1. vk6677
                        21.11.2023 13:43

                        Но, в Вашей крайней статье на этом сайте приведён код:

                        int hash = calc_hash((unsigned char*)argv[1]);

                        if(hash != 152) {

                        char failure[] = "Password is wrong\n";

                        write(1, failure, sizeof(failure));

                        return 1;

                        }

                        char success[] = "Password is right!\n"; write(1, success, sizeof(success));

                        И в коде строковые константные литералы в виде массивов.


                      1. includedlibrary
                        21.11.2023 13:43
                        +1

                        Я это осознанно сделал, так как строки будут храниться в коде, а код будет шифроваться AES. Это было нужно для усложнения реверс-инженеринга


                      1. vk6677
                        21.11.2023 13:43

                        Понял. Я был не прав. Для меня программирование это хобби. Будет стимул изучить эту тему. Даже не буду задавать вопросы про технические подробности.


              1. MiyuHogosha
                21.11.2023 13:43

                В Си++ точно не в стеке. литералы интернализируются. По Си нужно курить стандарт


                1. includedlibrary
                  21.11.2023 13:43

                  godbolt показывает обратное


                  1. MiyuHogosha
                    21.11.2023 13:43

                    а) про показывают реализациии - другой вопрос

                    б)GCC преспокойно интернализирует:

                    .LC0:

                            .string "Long enough string"

                    main:

                            push    rbp

                            mov     rbp, rsp

                            mov     QWORD PTR [rbp-8], OFFSET FLAT:.LC0

                            mov     eax, 0

                            pop     rbp

                            ret


                    1. includedlibrary
                      21.11.2023 13:43

                      Оказалось, что зависит от флагов оптимизации и длины строки. Если компилировать с -O0 или -O1 короткую строку, такую как в вашем и моём примерах, то она будет хранится в коде - двигаться в регистр, а регистр будет помещается в стек. Если компилировать с -O2 и выше, то эта же строка помещается в .data. Если длина достаточно большая, то при любом уровне оптмимизации она помещается в .data. Это работает и с g++ и с gcc одинаково. Спасибо, что привели контрпример


      1. mpa4b
        21.11.2023 13:43
        +1

        НЯП у ардуинок одними и теми же инструкциями нельзя лазить по любой памяти, по флешу одни инструкции, по РАМу другие. И потому там тип указателя в РАМ и тип указателя в флеш совсем разные. Одним const не обойтись.


        1. SIISII
          21.11.2023 13:43
          +1

          Да, там гарвардская архитектура, поэтому данные во флэше доступны лишь специально предназначенным для этого командам, а не обычным. Но хватает обычного const -- компилятор сам разберётся, какой код генерить. Хотя из-за гарвардской архитектуры бывают приколы. Скажем, если используешь любую функцию семейства printf, она там предполагает, что строка формата является константой и поэтому лежит во флэше. Обычно, конечно, так и бывает, но, если ты хочешь склепать строку динамически в оперативе, то работать не будет :)


          1. mpa4b
            21.11.2023 13:43

            Выглядит как костыли. const char * говорит, что указатель на неизменяемые байтики (функция не должна их менять), а не что те байтики исключительно в флеше могут быть.


            1. SIISII
              21.11.2023 13:43

              А это костыль и есть. В подавляющем большинстве случаев работает нормально, но в определённых ситуациях "первооткрывателя" ожидает сюрприз :) Особенно если человек на ассемблере не писал и не понимает по-настоящему разницу в архитектурах.


          1. voldemar_d
            21.11.2023 13:43
            +2

            Для Arduino Pro Micro, например, недостаточно написать const.

            Приходится ещё писать PROGMEM. Подробнее здесь:

            https://alexgyver.ru/lessons/progmem/


            1. mpa4b
              21.11.2023 13:43

              Ну а это уже формально делает указатель на то и на сё -- полностью несовместимыми.


              1. iig
                21.11.2023 13:43
                +1

                Ну там мало что архитектура гарвардская, так и доступ к данным из той памяти с интересным бубном делается. Там нет универсальных указателей.


                1. voldemar_d
                  21.11.2023 13:43

                  По ссылке выше все пляски с бубном описаны.


            1. shiru8bit
              21.11.2023 13:43
              +1

              Да для всех Arduino, и не только, нужно писать PROGMEM. На тех же ESP тоже надо - для всего с гарвардской архитектурой. Если просто написать const, оно будет при старте кода копироваться в ОЗУ.


              1. voldemar_d
                21.11.2023 13:43

                Спасибо за замечание. У меня не было возможности много разных Arduino перепробовать, только парочка в наличии есть. В какой-то статье на Хабре кто-то писал, что для какого-то Arduino достаточно static const написать, но я не вдавался в подробности, с какой он архитектурой. Возможно, это было все-таки не про Arduino, либо человек что-то напутал.


                1. shiru8bit
                  21.11.2023 13:43

                  На современных системах с фон Неймановской архитектурой неизменяемая память встречается вроде как редко, а вот в ретро-разработке иногда приходилось сталкиваться. Например, const размещает данные в постоянной неизменяемой памяти (rodata) в компиляторах для различных картриджных приставок. Там нужно помнить, что для массивов указателей на const данные const нужно указывать два раза - и в типе указателя, и в содержании. Типа, const unsigned char* const array[] = { .. }. Иначе сам массив указателей пойдёт в data, а не в rodata.


                  1. SIISII
                    21.11.2023 13:43

                    Любой микроконтроллер с архитектурой ARM имеет флэш-память -- которая вполне себе неизменяемая (ну да, в неё можно записать, но путём плясок с определёнными регистрами и т.п., а не простым выполнением команды записи по адресу).


      1. voldemar_d
        21.11.2023 13:43
        +3

        Иногда недостаточно написать const, чтобы константы оказались во флеш-памяти.

        Для некоторых Arduino, например, ещё надо написать PROGMEM.

        Но замечание абсолютно справедливое.


        1. iig
          21.11.2023 13:43
          +2

          Это avr-gcc - специфичный хак.

          Флеш-память она очень, очень всякая бывает.


  1. m0xf
    21.11.2023 13:43
    +9

    Для того, чтобы не засорять пространство имён при использовании windows.h, достаточно вынести платформенно-зависимый код в отдельный .c-файл.


  1. BenGunn
    21.11.2023 13:43
    +26

    Отличные примеры того как не стоит делать. Спасибо!


    1. iig
      21.11.2023 13:43
      +12

      Когда вижу вместо привычных uint_8, char *, етц какие-то свои типы - хочется взять и придушить спросить - "с какой целью ты это сделал?". И вот он ответ - потому что экономия байтов автор может.


      1. bodyawm
        21.11.2023 13:43
        -1

        Частенько для легкой переносимости между 16/32/64х битным кодом. Во времена DOS это было оч актуально. Зачем обзывать все типы своими именами - хз.


      1. SIISII
        21.11.2023 13:43
        -3

        Не всегда, не всегда. Например, char, во-первых, не обязан занимать 8 битов -- он должен быть не больше short int, но не более того. А во-вторых, нельзя предсказать, трактуется ли char как знаковое или беззнаковое число. Всё это потенциально создаёт проблемы с переносимостью. Ну а если использовать свой собственный тип, можно решить обе эти проблемы.


        1. MiraclePtr
          21.11.2023 13:43
          +6

          Ну а если использовать свой собственный тип, можно решить обе эти проблемы.

          Эти "собственные типы" - просто алиасы для существующих. Если вы сделайте #define чего-то как char'а, то вести оно себя будет точно так же как и char с теми же проблемами. Если вы вместо char хотите использовать uint8_t с гарантированным размером и беззнаковостью, то и используйте его, это стандартный тип, никаких алиасов и "собственных типов" не нужно.


          1. iig
            21.11.2023 13:43
            +1

            FcBool FcDirCacheUnlink (const FcChar8 *dir, FcConfig *config)
            

            А можете пояснить, зачем так?

            FcBool - явно переопределенный bool, FcChar8 - char*, и используются они именно так. Но понимать, что там происходит, очень тяжело.


            1. voldemar_d
              21.11.2023 13:43
              +1

              Вот здесь рекомендуют вообще не использовать bool, а использовать свой enum, особенно в параметрах функций. Потому как понять, что означает true, а что означает false, иногда ни разу не очевидно. Одно дело, когда это результат выполнения чего-то. А если это задает какое-то условие выполнения, легко запутаться, даже если возможных значений всего два. Лучше задать им явные собственные названия, по которым будет сразу очевидно, что это такое. Если речь про C++, нужно писать enum class для строгой типизации.


          1. SIISII
            21.11.2023 13:43

            Использовать #define для таких вещей -- вообще плохая идея, особенно если использовать С++ с его пространствами имён, а в перспективе и с модулями (ну, они введены в C++20, но до сих пор ещё не полностью работоспособны, особенно если нужна гарантированная переносимость между разными компиляторами). Для определения типа всё ж правильней использовать typedef (ну, ещё using, если речь о C++).

            Что "собственные типы" не совсем собственные -- это, конечно, так, но они позволят использовать одинаковые имена типов на любых платформах, даже если по каким-то причинам стандартные имена отсутствуют, реализованы криво и т.д. и т.п. (или вообще требуется компилировать для древней версии языка из-за тонны древнего кода).

            Что касается, например, стандартных типов вроде uint8_t, с ним есть две с половиной проблемы. Первая: их существование не гарантировано -- во всяком случае, в C++. Как утверждает cppreference.com (в сам стандарт мне смотреть лениво), "signed integer type with width of exactly 8, 16, 32 and 64 bits respectively with no padding bits and using 2's complement for negative values (provided if and only if the implementation directly supports the type)". Есть ещё два семейства вида int_fast8_t и int_least8_t -- они существуют всегда, начиная с C++11, но не гарантируют точный размер (плюс, как уже говорил, может возникнуть ситуация, когда приходится компилировать, используя более древнюю версию стандарта).

            Вторая проблема: логическое назначение типа. Если я вижу char, я предполагаю, что имею дело с символом или строкой символов, если вижу int -- что с целым числом или массивом чисел. Что технически это одно и то же (при одинаковых размерах, естественно), в данном случае неважно: правильное название типа помогает лучше понять, для чего служит переменная или параметр.

            Ну и полпроблемы: лично меня раздражают слишком уж длинные имена и _t в конце, хотя столь короткие имена, как автор статьи, я не использую: у меня int8, uint8, char8 (который всегда беззнаковый), ну и т.д. и т.п.


            1. domix32
              21.11.2023 13:43
              +1

              Не очень понятно чего вы так с плюсами оживились, когда речь именно про C. Дефайнить типы и тайпдефать классы/структуры в плюсах - зло, в C - необходимость.


              1. SIISII
                21.11.2023 13:43

                Дефайнить типы -- в любом случае зло. Ничто не мешает в чистых сях typedef использовать для простых типов, а не только для структур.


              1. MiyuHogosha
                21.11.2023 13:43

                typedef это создание псведонима, можно на все использовать.


                1. domix32
                  21.11.2023 13:43

                  В плюсах есть каноничный using для подобного.


        1. vk6677
          21.11.2023 13:43

          Вы правы.


      1. MiyuHogosha
        21.11.2023 13:43

        Когда типы по смыслу - это может быть подзказкой (см. линуксовые маны). А вот если uint8_t в u8... я тут готов присодиниться к асфикциации работе с сотрудником.


    1. includedlibrary
      21.11.2023 13:43
      +6

      Я согласен с двумя вещами: хранить длину строки вместе со строкой и возвращать из функции структуру, содержащуюю результат и код ошибки, если ошибка может произойти


  1. Gryphon88
    21.11.2023 13:43
    +1

    Не очень понимаю, при чем тут 2023? Все это можно было делать с С99 (из-за stdint), а остальное еще раньше.


  1. igor_suhorukov
    21.11.2023 13:43
    +2

    Самые веселые define, что можно придумать. exception прекрасен!
    // Подключение сети имён
    #define куярга using
    #define исемнәр namespace
    
    
    // Ввод-вывод
    #define Татарлар std
    
    #define карагыз cout
    #define тәртипсез cerr
    #define әйтегез cin
    #define икесен_алып_эшләргә swap
    
    #define юл_бетте endl
    
    
    // Татарские цифры
    #define нуль 0
    #define бер 1
    #define ике 2
    #define өч 3
    #define дүрт 4
    #define биш 5
    #define алты 6
    #define җиде 7
    #define сигез 8
    #define тугыз 9
    #define ун 10
    
    
    // Логические/булевые операторы
    #define ҺӘМ &&
    #define ЯКИ ||
    #define һәм &
    #define яки |
    #define яки_юк ^
    
    #define күбрәк >
    #define әзрәк <
    #define ул =
    #define күбрәк_яки_шул >=
    #define әзрәк_яки_шул <=
    #define шундый_ук ==
    #define шундый_түгел !=
    
    // Объявление переменных
    #define башларга int main
    #define сан int
    #define нокталы float
    #define ике_нокталы double
    
    #define бер_яклы unsigned
    #define кирәкмәгән_нәрсә void
    #define компьютерның_көче size_t
    #define зур long
    #define кечкенә short
    
    #define үзем_эшләдем template
    #define исеме_аның typename
    
    #define бирергә return
    #define яхшы 0
    #define ялгыш 1
    
    #define яңа new
    #define кирәкми delete
    #define БЕРНИ NULL
    #define белмим_кайда nullptr
    #define ниндидер random
    #define монысына_тимәскә const
    #define бетте_баш throw
    #define программаң_тупой exception
    
    
    // Условия, циклы
    #define булса if
    #define бу_булса else if
    #define юк_бит else
    #define була ?
    #define булганда while
    #define бөтенесенә for
    
    #define бетерергә break
    #define монысы_кирәкми continue
    #define шул_булса switch
    #define мәсәлән case
    #define юк_бит_шундыйлар default
    
    #define эшлә ()
    #define инде ;
    
    
    // ООП
    #define класс class
    #define структура struct
    #define сан_пар enum
    
    #define аныкы_гына private
    #define дусларына protected
    #define бөтен_кешегә public

    Ссылка на репозиторий проекта


    1. iig
      21.11.2023 13:43

      Тут что-то по татарски. Можно толмача?


  1. mpa4b
    21.11.2023 13:43
    +2

    Можно было бы воспользоваться _Bool, но я предпочитаю придерживаться естественного размера слова, не вдаваясь в его странную семантику. Начинающему читателю может показаться, что я просто «растрачиваю память», когда пользуюсь 32-разрядными булевыми значениями, но на практике это просто не так. Оно находится или в регистре (возвращаемое значение, локальная переменная), либо всё равно будет увеличиваться до нужного размера при помощи заполнителя (поле структуры).

    Как-то неубедительно.

    Во-1 все современные актуальные процессоры уже имеют регистры ("естественный размер") по 64 бита, давайте может лучше 8-байтный свой "бул" сделаем?

    Во-2 выравнивание в структурах будет иметь место только если следующий за байтом член -- не байт. Если подряд 10 байтовых типов -- каждый будет "невыровненным", точнее байты будут лежать подряд с адресами, различающимися на 1.

    Ну и ещё, опять же всем современным актуальным процессорам нет разницы, грузить ли в регистр байт или слово, например movzx (amd64), ldrb (aarch64), lb (risc-v), при этом автоматически делается расширение до всей ширины регистра.

    Наконец, если реально хочется экономить -- то логично использовать битфилды ( uint32_t variable_name:1 в таком вот духе)


    1. vk6677
      21.11.2023 13:43
      -2

      В языке Си изначально не было специального типа bool. Программист в праве выбрать любой тип в зависимости от архитектуры, скорости/компактности хранения данных.


      1. Arkasha
        21.11.2023 13:43
        +2

        это изначально было больше двадцати лет назад. даже в несвежих CentOS давно есть bool


        1. hydroargerum
          21.11.2023 13:43

          не двадцать, а пятьдесят. В этом году юбилей был


          1. Arkasha
            21.11.2023 13:43

            Это "изначально" закончилось больше двадцати лет назад

            Если вам так понятнее


            1. vk6677
              21.11.2023 13:43
              +1

              Вы правы, если речь о C++. Там тип bool так сказать, с рождения.

              Но в Си (без плюсов) тип _Bool появился как дополнительный в стандаре C99.

              И он является расширением, доступным при подключении <stdbool.h>.


              1. Arkasha
                21.11.2023 13:43
                +1

                Я о С и именно о С99. Не понимаю я людей, которые не юзают bool из <stdbool.h> // зная о его существовании


    1. SIISII
      21.11.2023 13:43
      +1

      Во-1 все современные актуальные процессоры уже имеют регистры по 64 бита, давайте может лучше 8-байтный бул сделаем?

      Тут Вы не правы. Если говорить о ПК или там планшетах -- да, так оно и есть. Но мир намного шире, и во всяких промышленных и встраиваемых системах вполне себе актуальны и 8-, и 16-, и 32-разрядные ядра, а вот 64-разрядные как раз не особо-то нужны в большинстве случаев (и, более того, часто непригодны вообще -- не из-за разрядности, конечно, а из-за того, что их использование тянет за собой, скажем, Линух с виртуальной памятью, что абсолютно не совместимо с задачами жёсткого реального времени). В частности, те же ARMы всех разновидностей архитектуры ARMv6-M, ARMv7-M, ARMv8-M являются 32-разрядными.

      Во-2 выравнивание в структурах будет иметь место только если следующий за байтом член -- не байт. Если подряд 10 байтов -- каждый будет невыровненным.

      Строго говоря -- выровненным. На свою естественную границу, конечно, -- 1-байтовую :)

      Ну и ещё, опять же всем современным актуальным процессорам нет разницы, грузить ли в регистр байт или слово, например movzx (amd64), ldrb (aarch64), lb (risc-v), при этом автоматически делается расширение до всей ширины регистра.

      Сама загрузка -- да, а вот тратить место в кэше под хранение лишних байтов... Кэш-то не резиновый, поэтому отводить под bool 32 или 64 бита вместо 8 -- не шибко хорошая идея, если брать в общем случае. (понятно, что, если структура состоит из кучи 32-разрядных значений и одного-единственного bool, там разницы уже не будет).

      Наконец, если реально хочется экономить -- то логично использовать битфилды (`uint32_t:1` в таком духе)

      Автор на это намекал, упомянув flags, но не разъяснив вопрос.


      1. mpa4b
        21.11.2023 13:43

        Тут Вы не правы.

        Я же просто потроллил автора статьи, что мол а давайте тогда уж 8-байтный бул использовать. :) В остальном я конечно же согласен, но автор упомянул десктопную ось, из чего я и сделал вывод про 64 бита.

        PS: в Cortex-M есть ровно те же самые ldrb/strb и там тоже нет никакого смысла булевые значения хранить в чём-то больше байта. Зато там есть расширение участка памяти из битов в 32-битные ворды (т.н. bitband), вот это реально могло бы быть причиной юзать 32-битные "булы", если бы не тот факт, что для доступа к биту структуры таким макаром пришлось бы делать сложное вычисление адреса, что свело бы на нет всю экономию. Но если хранится просто массив булов или в некоторых случаях при доступе в регистры периферии есть выигрыш от использования bitband.


  1. Hellpain
    21.11.2023 13:43
    +8

    Можно сразу на rust перейти:

    https://doc.rust-lang.org/book/ch03-02-data-types.html#integer-types

    • Length Signed Unsigned

    • 8-biti8 u8

    • 16-biti16 u16

    • 32-biti32 u32

    • 64-biti64 u64

    • 128-biti128 u128

    • archisize usize

    И строки https://doc.rust-lang.org/book/ch08-02-strings.html (указатель на данные на куче, длинна и емкость)

    Возвращаемые типы https://doc.rust-lang.org/book/ch09-02-recoverable-errors-with-result.html


    1. includedlibrary
      21.11.2023 13:43
      +3

      Только надо учитывать, что писать на расте не каждый захочет, мне, например, не понравилось


      1. pda0
        21.11.2023 13:43
        -1

        Но невозможно не заметить, что человек упорно изобретает rust.


        1. includedlibrary
          21.11.2023 13:43
          +1

          Не изобретает. Основная фишка раста - borrow checker, а не тип для строк и уж точно не возврат ошибок из функции (это и в го есть, например).


        1. KanuTaH
          21.11.2023 13:43

          Или плюсы, там тоже есть классы-строки, строковые слайсы (std::string_view), широко применяется возврат структур/классов, есть механизмы для оптимизации этого (copy elision). Но вообще конечно у гражданина есть и, мягко говоря, странноватые идеи, насчет ненужности const например.


  1. checkpoint
    21.11.2023 13:43
    +8

    Читал, читал, и не понял, табуляция или пробелы ?


    1. Arkasha
      21.11.2023 13:43

      Да, оно


    1. checkpoint
      21.11.2023 13:43
      +1

      ИМХО, называть строковый тип s8 это неверно. Это сразу создает путанницу с типами u8 и i8. Короче, такой минимализм в именовании типов это "too much" (или "too less").


    1. domix32
      21.11.2023 13:43

      Походу четыре пробела. Даже не два.


      1. pinbraerts
        21.11.2023 13:43

        Один же, зачем тратить символы


  1. pashigorev
    21.11.2023 13:43
    -1

    Зачем вы вообще выбрали С, есть же такие занимательные Java, Python и 1С


  1. MiyuHogosha
    21.11.2023 13:43

    БАГ!!! sizeof возвращает size_t который в Си беззнаков! А uintptr_t может иметь другой размер (например на RISC).

    Самое смешное, это очень древняя система, я ее видел в книге из.... 1987 года. Про интерактивную графику.


  1. vap1977
    21.11.2023 13:43

    Какой-то список антипаттернов - как делать ни в коем случае нельзя.
    Из существенного, случайно затесалась единственная идея, которую поддержу - возврат структур, явно описывающих полный результат выполнения, включая ошибки.