С - это неоднозначный язык. Сам по себе он небольшой, об этом писал Брайан Керниган в своей знаменитой книге "Язык программирования С". Но это не мешает комбинируя базовые возможности языка составлять более комплексные и запутанные конструкции. Иногда они встречаются в кодах реальных программ довольно редко, из-за своей узкой области применения - о них и пойдёт речь в этой статье.
Структура статьи
Сначала расскажу о базовых и часто встречаемых конструкциях, которые могут и вовсе не удивить опытных программистов на С, но вот уже ниже идут всё более редкие и интересные конструкции, которые уже далеко не часто можно где-либо встретить.
Содержание
union
register
Статические функции
Константный указатель
Препроцессорная склейка строк
Конкатенация строк во время компиляции
#undef
sizeof без ()
Создание функции в стиле K&R
Указатели на функции
Указатели на указатели
Функции с переменным количеством параметров
Массив + индекс (два в одном)
Индекс с массивом, но вверх ногами
strtok(), tmpfile()
Возврат указателя на функцию из функции
volatile
Макросы с переменным количеством параметров
auto
Использование возвращаемых значений scanf() и printf()
Каламбур типизации
Отрицательные индексы
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;
- указатель на charconst char* ptr;
- указатель на const charchar * const ptr;
- константный указатель на charconst 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);
будет следующая последовательность операций препроцессора:
printf(#exp " = %i\n", (int)(exp)); //исходная строка
printf(#(n/5) " = %i\n", (int)(n/5)); //вставка аргумента
printf("n/5" " = %i\n", (int)(n/5)); //конвертирование в строку
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 ячейки вправо. Теперь полученный адрес обязательно нужно привести к указателю на целую ячейку, и перейти по нему. Такой пример в отличии от предыдущего демонстрирует не сокращение объёма кода, а наоборот его увеличение, а что ещё хуже - увеличению количества операций которое нужно выполнить. Из этих примеров вывод такой - каламбур типизации в реальных программах это плохо потому, что
Размер ячеек на каждой платформе может отличаться
Разные компиляторы могут по разному хранить переменные в памяти
Оба этих фактора могут обеспечить неправильное или неопределённое поведение программы.
Отрицательные индексы
Изюминка этого чарта. 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
.