С - это неоднозначный язык. Сам по себе он небольшой, об этом писал Брайан Керниган в своей знаменитой книге "Язык программирования С". Но это не мешает комбинируя базовые возможности языка составлять более комплексные и запутанные конструкции. Иногда они встречаются в кодах реальных программ довольно редко, из-за своей узкой области применения - о них и пойдёт речь в этой статье.

Структура статьи

Сначала расскажу о базовых и часто встречаемых конструкциях, которые могут и вовсе не удивить опытных программистов на С, но вот уже ниже идут всё более редкие и интересные конструкции, которые уже далеко не часто можно где-либо встретить.

Содержание

  1. union

  2. register

  3. Статические функции

  4. Константный указатель

  5. Препроцессорная склейка строк

  6. Конкатенация строк во время компиляции

  7. #undef

  8. sizeof без ()

  9. Создание функции в стиле K&R

  10. Указатели на функции

  11. Указатели на указатели

  12. Функции с переменным количеством параметров

  13. Массив + индекс (два в одном)

  14. Индекс с массивом, но вверх ногами

  15. strtok(), tmpfile()

  16. Возврат указателя на функцию из функции

  17. volatile

  18. Макросы с переменным количеством параметров

  19. auto

  20. Использование возвращаемых значений scanf() и printf()

  21. Каламбур типизации

  22. Отрицательные индексы

union

Объединения в С встречаются в зависимости от задачи:

union cnt {
int ival;
float fval;
};

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

register

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

Статические функции

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

static char* get_next_password (const password_t stack[], ssize_t *sp){
return (sp > 0) ? stack[--*sp].data : NULL;
}

При беглом просмотре можно подумать что функция возвращает static char*, но на деле же она возвращает обычный указатель на строку, а вот статическое объявление говорит о том, что функция видна только в том файле исходного кода, в котором она описана. То есть если вы подключите заголовочный файл с этой функцией к другому, и попытайтесь вызвать её из другого файла, то будет ошибка о том что имя не найдено. Это некий механизм сокрытия имён, только в С. Такой приём часто используется в ядре ОС Linux. Но вот в небольших, а уж тем более одно-файловых программах вы такое вряд-ли найдёте. То же применимо и к статическим глобальным переменным - эффект такой-же как и с функциями. Применение я здесь увидел только одно - хранение 2-х функций с одинаковыми именами в 2-х разных файлах исходного кода.

Константный указатель

Указателем на константу уже никого не удивишь - const char*. Совсем другое дело константный указатель: char * const
Хоть я сам часто пишу на С, но о такой вещи не знал, и встречается она далеко не часто. Для ясности картины:

  • char* ptr; - указатель на char

  • const char* ptr; - указатель на const char

  • char * const ptr; - константный указатель на char

  • const char * const ptr; - константный указатель на const char

Конечно никто не запрещает делать так: char const *ptr;, то есть поменять char и const местами. Это также будет указателем на const char.

Препроцессорная склейка строк

Выглядит странно, но следующий фрагмент программы прекрасно компилируется, и запускается:

int ma\
in (void){
retu\
rn 0; }

Оператор \- это специальный инструмент препроцессора, позволяющий склеить текущую и следующую строку исходного кода перед компиляцией. Зачем это нужно? Например когда слишком длинное условие:

} while ((x >= 0) && (y >= 0) && (x <= WIN_SX-1) && (y <= WIN_SY-1) && flag && f(2));

Можно его разделить с помощью знаков \:

} while\
((x >= 0) &&\
(y >= 0) &&\
(x <= WIN_SX-1) &&\
(y <= WIN_SY-1) &&\
flag && f(2));

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

} while (access()); /* мм красота */

Также они применяются чтобы красиво оформить макросы

#define macro\
/* code */

Конкатенация строк во время компиляции

Продолжая тематику строк можно ещё вспомнить приём конкатенации строк во время компиляции. То есть если написать две строки рядом, то они склеятся в одну строку.

puts( "Hello, " "world!" ); - склеится в "hello, world!"

А вот это уже будет полезно для работы с длинными строками, чтобы можно было их переносить. Так как просто перенести, не закрыв и/или не открыв двойные кавычки не получится:

const char* ultra_string = "Lorem
ipsum..."; //ошибка

const char* ultra_string = "Lorem"
" ipsum..."; //склеится в "Lorem ipsum..."

Это свойство строк хорошо сочетается с препроцессорным оператором #,который переводит операнд в строку. Итак можно придумать такой отладочный вывод:

#define dprint(exp)\
printf(#exp " = %i\n", (int)(exp));

При вызове int n = 10; dprint(n/5); будет следующая последовательность операций препроцессора:

  1. printf(#exp " = %i\n", (int)(exp)); //исходная строка

  2. printf(#(n/5) " = %i\n", (int)(n/5)); //вставка аргумента

  3. printf("n/5" " = %i\n", (int)(n/5)); //конвертирование в строку

  4. printf("n/5 = %i\n", (int)(n/5)); //конкатенация строк

Вот простой пример применения, который был описан в книге Брайана Кернигана.

#undef

На самом деле применений у этой препроцессорной директивы не так уж и много. Разве что она может ограничить действие имени определённого через #define. Или же ставить его перед функциями так:

#undef to_str /* на всякий "пожарный" */
char* to_str (long val);

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

sizeof без ()

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

int *dyn_arr = malloc( sizeof *dyn_arr * A_SIZE);

Создание функции в стиле K&R

Вообще в стандарте C2X больше не позволительно объявлять функции так:

size_t move(a,b)
size_t a, b;
{
a = a ^ b;
b = a ^ b;
a = a ^ b;
}

К сожалению или к счастью практически все современные программы на С которые написаны на С11 и выше уже используют привычную всем конструкцию: тип аргумент, тип аргумент...

Указатели на функции

И всё, что вообще с ними связано. Я думаю они не сильно полезны, максимум как аргумент для другой функции, к примеру qsort() из заголовочного файла <stdlib.h> использует int (*compare)(const void*, const void*) в качестве параметра. Конечно говорить что они совсем не нужны категорически нельзя. Здесь всё зависит от задачи. Ещё они позволяют чуть лучше понять С и как он работает. Кстати в указатель на функцию можно положить как адрес функции, так и имя функции, так как
f = &f, напоминает ситуацию со статическими массивами: m = &m. Бывают полезны указатели на функции и в случае с массивом из функций, обращение к которому выглядит весьма необычно: (void)funcs[idx](param1,param2);

void parse_line(const char* input, rtable_line* line){
sscanf("%zu %zu %c\n", line->adress, line->hist_cnt, line->state);
}
...
void (*parse_func)(const char*, rtable_line*) = &parse_line; // 1 способ
parse_func = parse_line; //2 способ
parse_func(_input, _line); //вызов по указателю

В приведённом примере выше указать на функцию parse_line можно двумя способами: явно указав амперсанд, или не указывая его вовсе. 2 способа эквивалентны.

Указатели на указатели

на указатели на указатели на указатели...
То есть:

int *p; /* всё нормально - может быть переменная, а может быть массив */
int **p; /* это всё ещё переменная, а может быть двумерный массив. А может надо изменить указатель через его адрес */
int ***p; /* что-то много звёздочек. Трёх-мерный массив? Адрес адреса указателя? Переменная? */
int ****p; /* если вы пишите что-то такое не комментируя - обратитесь к врачу */
Как по мне такая конструкция может встретится на практике в таком виде:

void free_and_setto_NULL (void** ptr){
free(*ptr);
*ptr = NULL;
}

Но что-то я сомневаюсь что в таком случае нельзя обойтись макросом, или вовсе сделать все эти операции на месте вызова.

Функции с переменным количеством параметров

Как часто вы видите подобное?

#include <stdarg.h>
void print_strings (size_t count, ...){
va_list cstring;
va_start(cstring, count);
while (count > 0){
puts(va_arg(cstring, char*));
--count;
}
va_end(cstring);
}

Можно было например передать массив строк, тогда код получился бы короче:

void print_strings (size_t count, const char* strings[]){
while (count > 0)
puts(*strings++), --count;
}

Ну да, вызывать будет посложнее: print_strings( 2, (const char*[]){"I love C", "What am I doing?"});, но такой способ как минимум уменьшает объём кода в 3 раза. Но зато объявление функций с переменным количеством параметров выглядят красиво.

void print_strings( size_t, ... ); /* красота */

Стоит упомянуть, что в C функция, объявленная без параметров принимает произвольное число аргументов. Если вы не собирайтесь передавать в функцию никаких параметров, то при объявлении функции хорошей практикой будет написать void на месте параметров. В таком случае при передаче чего-либо в функцию компилятор не проигнорирует такой вызов в отличии от () на месте аргументов.

Массив + индекс (два в одном)

Редко (очень редко) где, я встречаю подобные выражения:

"Programming"[i++]

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

for (int i = 0; i < 10; i++){ putchar("@#c;:,. "[brightness[i]]); }

Здесь очень удобно проиллюстрирована возможность не создавать отдельный массив для символов яркости. Или:

printf ((char*[]){ "NO", "YES" } [state]);

Вот так можно обойти создание массива строк, хотя в таких ситуациях более понятным и читабельным всё же будет тернарный оператор ?:

Индекс с массивом, но вверх ногами

Простой пример:

int arr[ARR_SIZE], i = ARR_SIZE-1;
while (i>=0) {i--[arr] = i+1;}

Здесь может показаться что будет ошибка в выражении i--[arr], так как индекс и имя массива перепутаны местами. Но всё прекрасно работает - язык С, это язык возможностей. Вот незадача - оно работает, но как? При первом беглом взгляде я подумал об адресах. Быть может если arr представить как адрес, то тогда всё сходится, так как это целое число а индекс должен быть целым числом. Но меня ждал сюрприз во время разбора индекса, я не понял как из i--можно взять к примеру элемент под 3 индексом? 3 бит числа? Но потом прочитав объяснение всё сразу стало понятно.

Когда компилятор встречает выражение на подобие arr[i--], то он представляет его как *(arr + i--).Иными словами он прибавляет к адресу первой (нулевой) ячейки массива индекс в квадратных скобках, а потом по полученному адресу достаёт значение. А что будет если поменять местами индекс и массив? i--[arr] оттранслируется как *(i-- + arr). От перемены мест слагаемых сумма не меняется, следовательно и итоговый адрес тоже. Если ранее упомянутые конструкции находили себе хоть какое-то применение, то это просто забавный способ запутать программу для человека, который пока что не прочёл эту статью.

strtok(), tmpfile()

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

char str[50] = "Enjoy C programming!";
char* token = strtok(str, " .,!?"); //токены разделяются одним из символов d
do {
printf("token - \'%s\'\n", token);
token = strtok(NULL, " .,!?");
} while (token);

Такая функция может быть полезна для различного рода парсеров. Хотя для парсинга простых шаблонных строк больше подходит sscanf(). + ко всему функция является небезопасной, так как редактирует исходную строку s, которую она принимает на вход.

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

Возврат указателя на функцию из функции

int (*foo(void))(size_t, size_t);

Страшное объявление, но это всего лишь функция, возвращающая указатель на функцию, которая имеет сигнатуру int f(size_t,size_t). Хорошей практикой будет избегать таких объявлений, используя typedef:
typedef int (*funcptr)(size_t, size_t);
funcptr foo(void); //и всё понятно

Встречается такой стиль гораздо реже, но к нему надо стремиться. Ведь цель написать и быстрый и понятный код, а не только быстрый. (К программистам на Assemler не относится понятный, можете не волноваться по поводу читабельности). Да и вообще причиной ненависти к С у многих людей являются именно сложные объявления, а особенно те, в которых используются функции.

volatile

Иногда компилятор может оптимизируя удалять целые куски кода. Чтобы такого не было желательно ставить ключевое слово volatile перед объявлением переменной, если вы хотите чтобы компилятор 100% не применил свои коварные оптимизации к этой переменной. (Хорошо что слово volatile не может игнорироваться, как иногда бывает с register, а то в этой жизни пришлось бы больше ни в чём не быть уверенным)

Макросы с переменным количеством параметров

Вы думали всё заканчивается на функциях? К удивлению для меня в некоторых стандартах (а в частности и С11) можно делать макросы с переменным количеством аргументов, точно так же как и функции

#define vmacro(...)

А как к параметрам-то обратиться? Всё просто - тут вам никаких <stdarg.h>. Всего лишь в тексте для замены макросом нужно прописать __VA_ARGS__. Может показаться странным, но если вы подумайте как работает препроцессор - это просто обработчик текста, и он не может думать о том, как работать с этими аргументами и их типами. Точно также как и в макросы с фиксированным числом аргументов препроцессор С вставляет их на их места, так и здесь. Отличие лишь в том что в случае с переменным количеством параметров на место вставится не один, а столько сколько было указано в круглых скобках при макро-вызове аргументов.

#define myprint(str, ...)\
printf(str, __VA_ARGS__);

Также такой способ даёт почву для размышлений о том какие розыгрыши можно выдумать с таким инструментом:

#define for(...) //обезвредили все циклы for

auto

Не знаю зачем, может в ранних версиях языка без этого слова было никак не обойтись. В современных реалиях int x; объявленная внутри функции и так подразумевается автоматической, несмотря на то что здесь отсутствует явное auto int x;На моей теме VScode оно даже не подсвечивается как ключевое слово. Хотя в C++ оно активно используется в случае, когда программист абсолютно не переживает о том какой тип имеет переменная:

for (auto idx = 0; idx < 10; ++idx)

Использование возвращаемых значений scanf() и printf()

Что может быть абсурднее? Кто-то может первый раз слышать о том, что printf() из стандартной библиотеки возвращает кол-во напечатанных байтов на экран. Его братья sprintf(), vprintf(), fprintf() делают тоже самое. Соответственно scanf() возвращает кол-во считанных байт. Один раз видел даже такой код:

assert (printf("Some str") == strlen("Some str"));

Предохраниться лишний раз никогда не бывает лишним.

Каламбур типизации

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

Задача: дано шестнадцатеричное число uint64_t x = 0x2E6D6172676F7270;
В каждом байте числа записан символ. Вывести число как строку символов.

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

uint8_t cb; while (cb = x & 0xFF){ putchar(cb); x >>= 8; }

Такая запись вполне работает, и выводит каждый байт числа как символ. Но всегда есть альтернативные пути решения такой-же задачи, но заметно короче. "Короче" не всегда значит лучше!

printf ("%.8s", (char*)&x);

В один вызов printf уложилась задача, ранее решаемая с помощью цикла. Но как?
Дело в том что память в компьютере хранится в ячейках, и каждая ячейка имеет свой размер в байтах. Если представить x не как одно большое 8-байтное число как массив байтов (символов), то получим вот это:

10111001 10110101 10000101 11001001 10011101 10111101 11001000 00000000} x

Далее мы берём адрес первого байта этого числа:

10111001 10110101 10000101 11001001 10011101 10111101 11001000 00000000
^
&x

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

10111001 10110101 10000101 11001001 10011101 10111101 11001000 00000000 }
^
(char*)&x

И вот мы и получили строку, так как знаем что uint64_t это просто массив из 8 байт, и с помощью изменения адреса мы изменили представление памяти этого конкретного куска для компилятора, которому принципиально не важно что именно находится по адресу - надо лишь чтобы размер соответствовал. Формат %.8s неспроста имеет цифру 8. В ином случае мы рискуем вывести всё что лежит в памяти, пока не встретим 0. Поэтому поставим ограничение на 8 символов.

Почему это плохо?

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

struct point {
int x, y;
};
int get_y(const struct point* p){ return p->y; }

Выражение p->y здесь уж слишком банальное. Давайте усложним его

int get_y(const struct point* p){ return *(int*)((char*)&(p->x)+4); }

Здесь сначала берётся адрес переменной p->x. Затем зная что переменные в структуре идут в памяти по порядку мы можем сдвинутся на 4 байта вправо и получить адрес переменной p->y. Это производится с помощью приведения типа указателя на символ (байт), сдвижке его на 4 ячейки вправо. Теперь полученный адрес обязательно нужно привести к указателю на целую ячейку, и перейти по нему. Такой пример в отличии от предыдущего демонстрирует не сокращение объёма кода, а наоборот его увеличение, а что ещё хуже - увеличению количества операций которое нужно выполнить. Из этих примеров вывод такой - каламбур типизации в реальных программах это плохо потому, что

  1. Размер ячеек на каждой платформе может отличаться

  2. Разные компиляторы могут по разному хранить переменные в памяти

Оба этих фактора могут обеспечить неправильное или неопределённое поведение программы.

Отрицательные индексы

Изюминка этого чарта. arr[-1] - вполне себе привычная запись для программиста на Pascal. Всё потому что в этом языке при создании массива указывается не только правая, но ещё и левая граница массива:

var arr : array[-100..100] of integer;

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

int real_array[100], *arr = real_array+3;
arr[-1] = 3;
arr[-2] = 65;
arr[-3] = 3278;
printf("arr[-1] = %d, arr[-2] = %d, arr[-3] = %d\n", arr[-1], arr[-2], arr[-3]);

Если вы всё равно не поняли как это работает, то вот то как компилятор представил запись arr[-1]:

*(arr + (-1)) = 3;

Или же эквивалент ей:

*(real_arr + 2) = 3;

Такого вы точно скорее всего не видели в уже готовых работающих проектах на языке С.

Выводы

Язык С намного больше, обширнее и глубже чем кажется - необходимо как минимум несколько лет практики и опыта чтобы знать большинство возможностей этого инструмента. Хоть язык и имеет множество конструкций которые редко применяются, всё же они как никакие другие нужны в некоторых задачах. Я постарался дать примеры использования практически всех конструкций из этого списка. Надеюсь вы узнали что-то новое для себя из этой статьи, или хотя бы вспомнили хорошо забытое старое. Всем желаю чтобы ваши индексы находились в границах массивов, а malloc() никогда не возвращал NULL.