Fork me on GitHub

Идиомы в ANSI C

Шаблоны проектирования, они же просто паттерны, можно встретить на всех уровнях разработки программного обеспечения. «Низкоуровневые» шаблоны называются идиомами, поскольку они зависят от особенностей конкретного языка программирования. В данной статье описывается подборка таких вот идиом в чистом С (ANSI C), по большей части это вольный перевод Idiomatic Expressions in C от Adam Petersen.

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

Описываемые в данном материале идиомы можно условно разделить на две категории:

  • Идиомы для надёжности: помогают избежать распространенных ошибок в С. Например, идиома ИНИЦИАЛИЗАЦИЯ СОСТАВНЫХ ТИПОВ С ПОМОЩЬЮ {0} обеспечивает надёжный и переносимый способ инициализации структур и массивов.

  • Идиомы для выразительности: делают исходный код более читабельным и понятным, в результате поведение программы становится более предсказуемым, а процесс отладки проще и прозрачней. Например, идиома ДИАГНОСТИКА С ОПИСАНИЕМ позволяет добавить к механизму проверок (макрос assert) словесное описание возникшей проблемы.

SIZEOF ДЛЯ ПЕРЕМЕННЫХ

Функции стандартной библиотеки в С очень часто используют тип void* - указатель на объект неизвестного типа, это позволяет сделать их (функции) более универсальными и абстрагироваться от конкретного типа данных. Например результатом вызова функций для динамического выделения памяти (malloc, calloc и realloc) является указатель void*, при этом самим функциям на этапе компиляции ничего не известно о типе данных, для которых выделяется память. Вся ответственность ложится на код клиента, вызывающего функцию - именно он должен указать объём необходимый памяти:

HelloTelegram* telegram = (HelloTelegram*)malloc(sizeof(HelloTelegram));

Данный код легко читается и до тех пор, пока telegram остаётся HelloTelegram всё работает как надо. Однако в процессе сопровождения / изменений очень легко запутаться и допустить ошибку:

ByeTelegram* telegram = (ByeTelegram*)malloc(sizeof(HelloTelegram));

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

Используя идиому SIZEOF ДЛЯ ПЕРЕМЕННЫХ подобные ошибки можно предотвратить:

HelloTelegram* telegram = (HelloTelegram*)malloc(sizeof *telegram);

Но минуточку ! Не происходит ли тут разыменование указателя, который, ко всему прочему, к этому моменту ещё и не был инициализирован ? Нет, и причина, по которой это работает в том, что sizeof это оператор а не функция. Оператор sizeof не генерирует код, а выражение sizeof *telegram рассчитывается на этапе компиляции. Теперь, если тип telegram изменится ну скажем на ByeTelegram, компилятор автоматически пересчитает аргумент, передаваемый функции malloc. Таким образом, ответственность за корректный размер выделяемой памяти под заданный тип данных перекладывается с плеч программиста на компилятор. Поскольку в нашем примере telegram это указатель, то унарный оператор * в данном случае необходим.

Применение идиомы SIZEOF ДЛЯ ПЕРЕМЕННЫХ отнюдь не ограничивается указателями. Вот пример идиомы при работе с функцией memcpy, которая также использует void*, однако уже в качестве одного из аргументов:

uint32_t telegramSize = 0;
memcpy(&telegramSize, binaryDatastream, sizeof telegramSize);

Теперь, если изменится тип данных с uint32_t telegramSize = 0; на uintXX_t telegramSize = 0; код с memcpy останется без изменений и будет (должен) работать корректно.

ИНИЦИАЛИЗАЦИЯ СОСТАВНЫХ ТИПОВ С ПОМОЩЬЮ {0}

Неинициализированные переменные - распространенный источник ошибок в любых программах. В С инициализация примитивных типов данных, таких как int, char и т.д. осуществляется очень просто, но как правильно инициализировать составные типы, такие как массивы или структуры ? Опасной, но увы, достаточно распространённой практикой является вот такой способ:

struct TemperatureNode {
    double todaysAverage;
    struct TemperatureNode* nextTemperature;
};

struct TemperatureNode node;
memset(&node, 0, sizeof node);

Проблема с использованием memset заключается в том, что данная функция просто обнуляет биты, не заботясь о деталях внутренней реализации источника данных, с которым ей приходится работать. В С можно встретить типы данных, например NULL - указатель или числа с плавающей запятой, у которых все биты в ноль ещё не означают, что фактическое значение экземпляра тоже будет ноль. Таким образом, можно получить программу, поведение которой отличается от ожидаемого.

Альтернативный способ инициализации структуры путём явной пошаговой инициализации её полей выглядит громоздко и его трудно сопровождать. Даже если избежать дублирования кода (например с использованием отдельных функций, которые будут инициализировать необходимые значения), каждый раз при добавлении / удалении у структуры полей необходимо вносить изменения и в код, занимающийся инициализацией.

К счастью, тут можно задействовать идиому ИНИЦИАЛИЗАЦИЯ СОСТАВНЫХ ТИПОВ С ПОМОЩЬЮ {0}. Данный приём гарантирует корректную инициализацию всех полей структуры (включая числа с плавающей запятой и т.д.) нулевыми значениями. Переносимость данного кода для конкретной целевой платформы обеспечивается компилятором:

struct TemperatureNode node = {0};

Также данная идиома может быть полезна при обнулении уже инициализированных массивов или полей структур в любом месте программы, для этой цели необходимо создать эталон (лучше константу const), а его значение по мере необходимости копировать:

/* Эталонная структура с нулевыми полями */

const struct TemperatureNode zeroNode = {0};
struct TemperatureNode node = {0};

/* Что-то делается с структурой node. */
...
/* Необходимо обнулить все поля node */

node = zeroNode;

При обнулении массивов операция простого присваивания не сработает - необходимо перебирать все элементы в цикле. Как вариант, можно задействовать memcpy:

const double zeroArray[NO_OF_TEMPERATURES] = {0};
double temperatures[NO_OF_TEMPERATURES] = {0};

/* Что-то делается с массивом temperatures. */
...
/* Обнулить все элементы массива temperatures. */

memcpy(temperatures, zeroArray, sizeof temperatures);

Для многомерных массивов и вложенных структур могут понадобиться дополнительный фигурные скобки например {{0}}.

КОЛИЧЕСТВО ЭЛЕМЕНТОВ МАССИВА КАК РЕЗУЛЬТАТ ДЕЛЕНИЯ

Как известно, С не изобилует удобствами при работе с массивами. При передаче массива в качестве аргумента функции, попросту передаётся указатель на его первый элемент. Без дополнительных соглашений (пример такого соглашения - строка, тот же массив, но он всегда завершается нулевым символом), этой информации недостаточно, чтобы в теле функции узнать количество элементов массива. В результате большинство API вешают всю чёрную бухгалтерию по вычислению количества элементов массива на программиста. Одним из примеров может быть функция poll() в POSIX API. В качестве первого аргумента ей передаётся массив структур pollfd:

struct pollfd handles[NO_OF_HANDLES] = {0};

/* Заполняется массив необходимыми значениями */

int result = poll(handles, NO_OF_HANDLES, TIMEOUT);

Проблема данного кода в том, что за исключением красивого имени ничего не указывает на то, что константа NO_OF_HANDLES это реальное количество элементов в массиве. Хорошие имена имеют большое значение, но только для программиста. Компилятору они ни о чем не говорят, он не будет ругаться на такой, явно подозрительный код с "душком":

#define NO_OF_HANDLES x
struct pollfd handles[NO_OF_HANDLES] = {0};

/* Заполняется массив необходимыми значениями */

#undef NO_OF_HANDLES
#define NO_OF_HANDLES y
int result = poll(handles, NO_OF_HANDLES, TIMEOUT);

При использовании идиомы КОЛИЧЕСТВО ЭЛЕМЕНТОВ МАССИВА КАК РЕЗУЛЬТАТ ДЕЛЕНИЯ, мы можем гарантировать, что вычисляемое на этапе компиляции количество элементов массива всегда будет равным реальному количеству элементов в массиве:

struct pollfd handles[NO_OF_HANDLES] = {0};

/* Заполняется массив необходимыми значениями */

const size_t noOfHandles = sizeof handles / sizeof handles[0];
int result = poll(handles, noOfHandles, TIMEOUT);

В основе идиомы лежит утверждение, что результатом деления размера всего массива на размер одного из его элементов является общее количество элементов.

ENUM ДЛЯ ЦЕЛОЧИСЛЕННЫХ КОНСТАНТ

В предыдущем примере размерность массива задаётся макросом #define NO_OF_HANDLES, но это же можно сделать и более строгим способом через перечисление enum:

enum {NO_OF_HANDLES=42};
struct pollfd handles[NO_OF_HANDLES] = {0};

Такое решение более надёжно т.к. для enum нельзя сделать какую-нибудь пакость типа #undef. Ещё можно было бы попробовать задать размерность и с помощью const int NO_OF_HANDLES=42;, правда такой способ возможен только внутри функций и поэтому не особо практичен в отличие от enum.

МАГИЧЕСКИЕ ЧИСЛА КАК ПЕРЕМЕННЫЕ

Что за 42 !? Опытные программисты избегают магических чисел, потому что они делают код трудным для понимания и могут запутать любого, кто будет его читать / сопровождать. Правда есть некоторые числа - 0, 1, 3.14 и 42, которые принято считать менее магическими, чем остальные. Когда мы видим переменную, инициализируемую по умолчанию 0, можно предположить, что она будет изменяться где-то дальше в коде. Число 1 может быть просто аналогом true. Число 42 универсально по своей природе, оно отвечает на главный вопрос жизни, вселенной и всего такого.

Как ни странно, с не очень магическими числами тоже могут быть проблемы. Например у структуры struct tm, которая cодержит компоненты календарного времени в С, поле int tm_year; хранит годы с 1900 (в Windows) или с 1970 (в Unix). Таким образом нулевое значение этого поля на самом деле ещё не означает нулевой год.

Ну а с настоящими магическими числами всё ещё печальней:

startTimer(10, 0);

Несмотря на хорошее имя функции, сложно однозначно сказать, что тут происходит. Если предположить, что 10 - это временной интервал, то в чём он измеряется ? Секунды, миллисекунды или ёжики ? А может это вообще какой-то порядковый номер ? Со вторым параметром такая же история.

Шагом навстречу к самодокументирующемуся коду будет идиома МАГИЧЕСКИЕ ЧИСЛА КАК ПЕРЕМЕННЫЕ. Согласитесь, данный код вызывает намного меньше вопросов:

const size_t timeoutInSeconds = 10;
const size_t doNotRescheduleTimer = 0;

startTimer(timeoutInSeconds, doNotRescheduleTimer);

Следуя такому принципу где-то дальше в коде можно по аналогии написать:

startTimer(tenSecondsTimeout, doNotRescheduleTimer);

Код стал ещё более понятным, не так ли ? Или не совсем ? Подвох тут в том, что для компилятора tenSecondsTimeout это просто имя. Ничто не мешает какому-то слишком умному программисту присвоить переменной tenSecondsTimeout не совсем то число, которое вытекает из её имени:

const size_t tenSecondsTimeout = 42; /* Original value was 10 */

Такое иногда случается, и в процессе отладки можно долго думать, сколько же на самом деле длятся 10 секунд...

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

ИМЕНОВАННЫЕ АРГУМЕНТЫ

Как мы уже знаем, следуя принципу МАГИЧЕСКИЕ ЧИСЛА КАК ПЕРЕМЕННЫЕ можно сделать код более читабельным, но, как видно из предыдущего примера, бывают случаи, когда подобрать хорошее имя для переменной проблематично. Поэтому давайте немного переделаем предыдущий пример. Допустим, переменной, которая используется для запуска первого таймера, можно дать имя timeoutInSeconds, но какое имя тогда стоит давать аналогичной переменной для второго таймера ? Как вариант, можно попробовать повторно использовать timeoutInSeconds, предварительно убрав ключевое слово const:

size_t timeoutInSeconds = 10;
const size_t doNotRescheduleTimer = 0;

notifyClosingDoor = startTimer(timeoutInSeconds, doNotRescheduleTimer);

timeoutInSeconds = 12;
closeDoor = startTimer(timeoutInSeconds, doNotRescheduleTimer);

Трудно назвать такой код читабельным и надёжным. Концепция ИМЕНОВАННЫХ АРГУМЕНТОВ могла бы решить данную проблему, позволяя явно указывать имя передаваемого в метод параметра, вместо идентификации его по положению. С напрямую не поддерживает такую возможность, однако всё же имеется один способ эмулировать похожее поведение:

size_t timeoutInSeconds = 0;
const size_t doNotRescheduleTimer = 0;

notifyClosingDoor = startTimer(timeoutInSeconds=10, doNotRescheduleTimer);

closeDoor = startTimer(timeoutInSeconds=12, doNotRescheduleTimer);

В данном случае переменной timeoutInSeconds по-прежнему присваивается заданное значение, после чего она передаётся как аргумент функции startTimer. Теперь код неплохо выглядит, однако при разработке встраиваемых приложений, когда каждый байт памяти на счету, такой подход может оказаться слишком расточительным. В подобных случаях ничто не мешает вместо использования timeoutInSeconds просто задействовать дополнительные комментарии:

closeDoor = startTimer(/*Timeout in seconds*/12,/*Don't reschedule*/0);

Чем больше комментариев в коде тем веселей его читать.

ДИАГНОСТИКА С ОПИСАНИЕМ

В стандартной библиотеке C имеется механизм для диагностики программы на этапе выполнения - макрос assert, который позволяет контролировать поведение программы, обнаруживая потенциальные ошибки. Единственным аргументом для assert является выражение, которое в нормальных условиях должно быть истинно. В этом случае ничего не происходит и программа идёт дальше. В противном случае assert печатает сообщение в стандартный поток stderr и вызывает функцию abort() для завершения выполнения программы.

Никому не хочется разбираться в длинных функциях, в этом случае мы стараемся максимально упростить код, преобразуя его фрагменты в отдельные, более маленькие функции и передавая им какие-то данные. Используя макрос assert, функция может проверить переданные ей аргументы перед выполнением своей основной логики и тем самым выявить проблему на самом раннем этапе:

void sayHelloTo(const Address* aGoodFriend) {
    if(aGoodFriend) {
        Telegram friendlyGreetings = {0};

        /* Заполняем телеграмму telegram... */

        sendTelegram(&friendlyGreetings, aGoodFriend);
    }
    else {
        error("Empty address not allowed");
    }
}

static void sendTelegram(const Telegram* telegram, 
    const Address* receiver) {

    /* Проверка. Телеграмма существует ? */
    assert(telegram);
    /* Проверка. Получатель существует ? */
    assert(receiver);

    /* Отсылаем телеграмму... */
}

Формат диагностического сообщения может немного отличаться в зависимости от компилятора, но приблизительно должно быть что-то такое:

Assertion failed: expression, file filename, line line number

Суть идиомы ДИАГНОСТИКА С ОПИСАНИЕМ в том, что в выражение (expression) можно подмешать строку - комментарий. Это позволит не только диагностировать место, в котором возникла проблема, но и интуитивно понятно описать её:

assert(receiver && "Is validated by the caller");

Главным недостатком такого подхода является дополнительный объём памяти, в котором будут храниться строки с комментариями. Для встраиваемых приложений это особенно актуально. Однако не стоит забывать, что макрос assert - это инструментарий для отладки, его в любое время можно отключить (NDEBUG).

СТАТИЧЕСКАЯ ДИАГНОСТИКА

Диагностировать ошибки в программе на этапе компиляции обычно выходит дешевле чем тестировать продукт на реальных пользователях. Например парочка макросов для битовых операций:

#define BIT_SET(a,b) ((a) |= (1<<(b)))
#define BIT_CLEAR(a,b) ((a) &= ~(1<<(b)))
#define BIT_FLIP(a,b) ((a) ^= (1<<(b)))
#define BIT_CHECK(a,b) ((a) & (1<<(b)))

char a = 0;

// OK
BIT_SET(a, 0);

// BAD: 10 > 8 * sizeof(a)
BIT_SET(a, 10);

Очевидно операция BIT_SET(a, 10); не имеет смысла для переменной 8 бит и такой класс ошибок можно и нужно обнаруживать ещё на этапе компиляции. Существуют много различных способов это сделать.

#define STATIC_ASSERT(cond) typedef int foo[(cond) ? 1 : -1]

#define BIT_SET(a,b) STATIC_ASSERT(8*sizeof(a)>b); ((a) |= (1<<(b)))

char a = 0;

// OK
BIT_SET(a, 0);

// compile error
BIT_SET(a, 10);

Теперь логически неправильный код BIT_SET(a, 10); попросту не соберётся что и требовалось в постановке задачи.

КОНСТАНТЫ СЛЕВА

В стандарт С вошла одна загадочная конструкция, в которой легко запутаться / очепятаться (мы с ней отчасти уже пересекались при описании идиомы ИМЕНОВАННЫЕ АРГУМЕНТЫ):

int x = 0;

if (x = 0) {
    /* Сюда никогда не попадём (Индусский код) ! */
}

if (x == 0) {
    /* А вот сюда попадём... */
}

Иногда подобные выражения всё же имеют скрытый смысл:

Friend* aGoodFriend = NULL;
...
if (aGoodFriend = findFriendLivingAt(address)) {
    sayHelloTo(aGoodFriend);
}
else {
    printf("I am not your friend");
}

С помощью идиомы КОНСТАНТЫ СЛЕВА программист может более точно указать компилятору, что он имел ввиду операцию сравнения, а не присваивания. Следующая операция присваивания не имеет смысла и поэтому компилятор должен ругаться:

if (0 = x) {
}

А вот подобная операция сравнения не противоречит здравому смыслу:

if (0 == x) {
    /* Если х равен нулю, нам сюда */
}

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

ИНКАПСУЛЯЦИЯ ДАННЫХ

Взглянем на супер реализацию кольцевого буфера:

circular_buffer.h

static void shift_circular(void);

void circular_add_1(void);

void circular_add_0(void);

void circular_print(void);

circular_buffer.c

#include "circular_buffer.h"
#include <stdio.h>

static unsigned char rxbuf[1 + (4*sizeof(long))];

static void shift_circular(void) {
    *(( unsigned long* )&rxbuf[12]) <<= 1; 
    if (rxbuf[11] & 0x80) rxbuf[12] |= 1; 
    *(( unsigned long* )&rxbuf[8]) <<= 1; 
    if (rxbuf[7] & 0x80) rxbuf[8] |= 1; 
    *(( unsigned long* )&rxbuf[4]) <<= 1; 
    if (rxbuf[3] & 0x80) rxbuf[4] |= 1; 
    *(( unsigned long* )&rxbuf[0]) <<= 1; 
}

void circular_add_0(void) {
    shift_circular(); 
    rxbuf[0] &= 0xFE;
}

void circular_add_1(void) {
    shift_circular(); 
    rxbuf[0] |= 1;
}

void circular_print(void) {
    char i = sizeof(rxbuf); 
    while(i--) 
        printf("%.2x", rxbuf[i]);
}

usage.c

circular_add_1();
circular_add_0();
circular_print();

Что будет, когда в пределах одной программы нужно использовать более одного кольцевого буфера ? Поскольку собаководы ANCI C не рекомендуют задавать новый тип данных как массив с заданной величиной, данные буфера обёрнуты в структуру CircularBuffer:

circular_buffer.h

typedef struct CircularBuffer {
    unsigned char bytes[1 + (4*sizeof(long))];
} CircularBuffer;

static void shift_circular(char*);

void circular_add_1(CircularBuffer*);

void circular_add_0(CircularBuffer*);

void circular_print(CircularBuffer*);

circular_buffer.c

#include "circular_buffer.h"
#include <stdio.h>

static void shift_circular(char* rxbuf) {
    *(( unsigned long* )&rxbuf[12]) <<= 1; 
    if (rxbuf[11] & 0x80) rxbuf[12] |= 1; 
    *(( unsigned long* )&rxbuf[8]) <<= 1; 
    if (rxbuf[7] & 0x80) rxbuf[8] |= 1; 
    *(( unsigned long* )&rxbuf[4]) <<= 1; 
    if (rxbuf[3] & 0x80) rxbuf[4] |= 1; 
    *(( unsigned long* )&rxbuf[0]) <<= 1; 
}

void circular_add_0(CircularBuffer* circular_buffer) {
    shift_circular(circular_buffer->bytes); 
    circular_buffer->bytes[0] &= 0xFE;
}

void circular_add_1(CircularBuffer* circular_buffer) {
    shift_circular(circular_buffer->bytes); 
    circular_buffer->bytes[0] |= 1;
}

void circular_print(CircularBuffer* circular_buffer) {
    char i = sizeof(*circular_buffer); 
    while(i--) 
        printf("%.2x", circular_buffer->bytes[i]);
}

usage.c

static CircularBuffer a,b;
/* a.bytes[0] = 42; компилятор проглотит, не по феншую :( */
circular_add_1(&a);
circular_add_0(&a);
circular_print(&a);

К сожалению структура circular_buffer описана в circular_buffer.h, а значит видна везде где подключается этот заголовочный файл. Побороть это можно с помощью т.н. незавершённого типа данных и фабрики:

circular_buffer.h

typedef struct CircularBuffer* CircularBufferPtr;

static void shift_circular(char*);

CircularBufferPtr circular_create(void);

void circular_add_1(CircularBufferPtr);

void circular_add_0(CircularBufferPtr);

void circular_print(CircularBufferPtr);

circular_buffer.c

#include "circular_buffer.h"
#include <stdio.h>
#include <stdlib.h>

struct CircularBuffer {
    unsigned char bytes[1 + (4*sizeof(long))];
};

static void shift_circular(char* rxbuf) {
    *(( unsigned long* )&rxbuf[12]) <<= 1; 
    if (rxbuf[11] & 0x80) rxbuf[12] |= 1; 
    *(( unsigned long* )&rxbuf[8]) <<= 1; 
    if (rxbuf[7] & 0x80) rxbuf[8] |= 1; 
    *(( unsigned long* )&rxbuf[4]) <<= 1; 
    if (rxbuf[3] & 0x80) rxbuf[4] |= 1; 
    *(( unsigned long* )&rxbuf[0]) <<= 1; 
}

CircularBufferPtr circular_create() {
    CircularBufferPtr p = malloc(sizeof * p);
    return p;
}

void circular_add_0(CircularBufferPtr circular_buffer) {
    shift_circular(circular_buffer->bytes);
    circular_buffer->bytes[0] &= 0xFE;
}

void circular_add_1(CircularBufferPtr circular_buffer) {
    shift_circular(circular_buffer->bytes);
    circular_buffer->bytes[0] |= 1;
}

void circular_print(CircularBufferPtr circular_buffer) {
    char i = sizeof(*circular_buffer); 
    while(i--) 
        printf("%.2x", circular_buffer->bytes[i]);
}

usage.c

CircularBufferPtr a = circular_create(), b = circular_create();
/* a->bytes[0] = 42; ошибка: доступ по указателю на неполный тип */
circular_add_1(a);
circular_print(a);
free(a);
free(b);

Незавершённый тип данных иногда называют неполным, а в оригинале на англ. incomplete type.

Comments !

links

social