Советы по языку программирования Си: 10 полезных приемов

Си — это один из самых важных и широко распространённых языков программирования. Его можно использовать не только для общих целей, но и для написания низкоуровневых программ, работающих с «железом». Си позволяет программисту многое из того, чего не позволяют другие языки. Однако в этом кроется как сильная, так и слабая сторона языка: можно писать высокопроизводительный код, но гораздо проще выстрелить себе в ногу. Поэтому мы делимся с вами десятью советами, которые пригодятся как начинающим, так и опытным Си-разработчикам. 

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

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

Этот приём заключается в следующем. Сперва нужно задать тип «указатель на функцию, возвращающую что-то» и использовать его для объявления переменной. Рассмотрим простой пример. Сначала я задаю тип PFC (Pointer to a Function returning a Character):

Затем использую его для объявления переменной z:

Определяю функцию a():

Адрес функции теперь хранится в z:

Заметим, что вам не нужен оператор & ("address-of"); компилятор знает, что a должна быть адресом функции. Так происходит из-за того, что с функцией можно произвести лишь две операции: 1) вызвать её или 2) взять её адрес. Поскольку вызова функции не происходит (отсутствуют скобки), остаётся лишь вариант с получением адреса, который помещается в z.

Чтобы вызвать функцию, адрес которой находится в z, просто добавьте скобки:


2. Списки аргументов переменной длины

Обычно вы объявляете функцию, которая принимает фиксированное число аргументов. Тем не менее, можно написать функцию, которая принимает любое их количество. Стандартная функция printf() тому доказательство. Разумеется, вы можете сами написать подобную функцию. Вот пример:

Первый аргумент, arg_count — это целое, в котором хранится реальное число аргументов, следующих за ним в списке переменных, указанном как три точки.

С переменными аргументами работают несколько встроенных функций и макросов: va_list, va_start, va_arg и va_end (они определены в stdarg.h).

Сперва вам нужно объявить указатель на список аргументов:

Затем установите argp в первый аргумент переменной части. Это первый аргумент после последнего фиксированного (в нашем случае arg_count):

Теперь извлекаем каждую переменную по очереди, используя va_arg:

Заметим, что вам нужно знать тип аргумента заранее (в нашем случае int) и число аргументов (у нас задаётся фиксированным arg_count).

Наконец, нужно убраться при помощи va_end:


3. Проверка и установка отдельных битов

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

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

Если они как-то связаны, как в примере выше (они все описывают состояние движения), то зачастую бывает удобнее хранить их в одной «переменной состояния» и использовать каждый бит для задания того или иного состояния:

Тогда вы сможете использовать битовые операции для задания или обнуления отдельных битов:

Преимуществом является то, что вся информация хранится в одном месте, и очевидно, что вы работаете с одной логической сущностью.

В архиве с кодом, который будет дан в конце статьи, есть пример работы с битами.

Чтобы установить заданный бит переменной value (в диапазоне от 0 до 31), используйте такое выражение:

Для очистки бита используйте:

А для получения значения бита:


4. Ленивые логические операторы

Логические операторы Си, && («и») и || («или»), позволяют вам составлять цепочки условий в тех случаях, когда действие должно выполняться при выполнении всех условий (&&) или только одного (||). Но в Си также есть операторы & и |. Очень важно понимать разницу между ними. Если вкратце, двухсимвольные операторы (&& и ||) называются «ленивыми» операторами. Если они используются между двумя выражениями, то второе будет выполнено, только если первое оказалось верным, иначе оно пропускается. Рассмотрим пример:

Тест (int)f && feof(f) должен вернуть истинный результат, когда будет достигнут конец файла f. Сперва тест вычисляет f; она будет равна нулю (ложное значение), если файл не был открыт. Это ошибка, поэтому попытка чтения файла не увенчается успехом. Однако, поскольку первая часть теста провалена, вторая не будет обработана, и feof() не будет запущена. Этот пример демонстрирует корректное использование ленивого оператора. Теперь посмотрите на этот код:

В этом случае используется оператор &, а не &&. Оператор & — это инструкция для выполнения обоих выражений при любых условиях. Поэтому, даже если первая часть теста провалится, вторая будет выполнена. Это может привести к различным ошибкам.


5. Тернарные операторы

Операция называется тернарной. когда принимает три операнда. В Си тернарный оператор ? : можно использовать для сокращённой записи тестов if..else. Общий синтаксис выглядит так:

Пусть у нас есть две целых переменных, t and items. Мы можем использовать if..else для проверки значения items и присваивания её значения переменной t таким образом:

Используя тернарный оператор, эту запись можно сократить до одной строки:

2

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

Рассмотрим ещё один пример. Этот код выводит первую строку, когда у нас один предмет, и вторую, когда их несколько:

Это можно переписать так:


6. Стек

Стек — это LIFO-хранилище. Вы можете использовать адресную арифметику для добавления элементов в стек или извлечения их из него. Часто под стеком подразумевается структура, используемая Си для хранения локальных переменных функции. Но на самом деле стек — это общий тип структуры данных, которым вы спокойно можете пользоваться.

Код ниже задаёт очень маленький стек: массив _stack из двух целых. Помните, что при тестировании всегда лучше использовать небольшие числа. Если код содержит ошибки, найти их при работе с массивом из 2 элементов будет проще, чем если их будет 100. Также объявляется указатель на стек _sp и устанавливается в основание стека _stack:

Теперь определим функцию push(), которая помещает целое в стек. Она возвращает новое число элементов в стеке или -1, если стек полон:

Для получения элементов стека нужна функция pop(). Она возвращает новое число элементов в стеке или -1, если он пуст:

А вот пример, демонстрирующий работу со стеком:


7. Копирование данных

Вот три способа копирования данных. Первый использует стандартную функцию memcpy(), которая копирует n байт из src в dst:

Теперь посмотрим на самодельную альтернативу memcpy(). Она может быть полезной, если копируемые данные нужно как-то обработать:

И наконец, функция, использующая 32-битные целые для ускорения копирования. Помните, что скорость в конечном итоге зависит от оптимизации компилятора. В этом примере предполагается, что счётчик данных n кратен 4 из-за работы с 4-байтовыми указателями:

Примеры можно найти в архиве ниже.


8. Использование заголовочных файлов

Си использует заголовочные файлы (.h), которые могут содержать объявления функций или констант. Заголовочный файл можно импортировать в код двумя способами: если файл предоставляется компилятором, используйте #include <string.h>, а если файл написан вами — #include "mystring.h". В сложных программах есть риск того, что вы можете подключить один и тот же заголовочный файл несколько раз.

3

Предположим, что у нас есть простой заголовочный файл, header1.h, содержащий следующие определения:

Затем создадим другой файл header2.h, содержащий это:

Добавим в нашу программу, main.c, это:

При компиляции программы мы получим ошибку компиляции, потому что T_SIZE будет объявлена дважды (её определение в header1 подключено к двум разным файлам). Мы должны подключить header1 к header2 для того, чтобы header2 компилировался в тех случаях, когда header1 не используется. И как это исправить? Можно написать макрос для header1:

Эта проблема настолько распространена, что многие IDE делают это за вас. Тем не менее, если вы будете делать это вручную, делайте это осторожно.


9. Скобки: нужны ли они?

Вот несколько простых правил:

  1. Скобки нужно использовать для изменения порядка выполнения операторов. Например, 3 * (4 + 3) — не то же самое, что 3 * 4 + 3 .
  2. Скобки можно использовать для улучшения читаемости. Здесь они, очевидно, не нужны:
    Приоритет оператора || ниже, чем < и >. Однако, в этом случае скобки точно не будут лишними:
  3. Скобки стоит использовать в макросах:
    Вы не знаете, где будете использовать эту константу, поэтому скобки нужны. Рассмотрим такой случай применения:
    Без скобок результат получился бы неверным из-за порядка выполнения операторов.

Но в одном месте скобки точно не нужны: в выражении после return. Например, это…

…выполнится так же, как это:


10. Массивы как адреса

Программисты, которые учат Си после какого-то другого языка, часто удивляются, когда Си работает с массивами как с адресами и наоборот. Массив — это контейнер фиксированного размера, а адрес — это число, связанное с местом в памяти; разве они связаны?

Выходит, что да.

Си прав: массив — это просто адрес базы в блоке памяти, а форма записи массива, например, в Java или JavaScript — просто синтаксический сахар.

Присмотритесь к этому коду:

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

На каждой итерации адрес увеличивается на i. Поэтому адрес переменной _x будет первым элементом, а каждый следующий адрес — адресом  _x плюс 1. Когда мы прибавляем 1 к адресу массива, компилятор Си вычисляет подходящий сдвиг в зависимости от типа данных (в нашем случае 4 байта для массива целых).

Второй цикл выводит значения, хранящиеся в массиве, сперва выводя адрес элемента _x + i, затем значение элемента через привычный вид массива _x[i], а потом содержимое массива с использованием адресной нотации (где оператор * возвращает содержимое памяти по адресу в скобках): *(_x + i). Во всех случаях значения будут одинаковыми. Это наглядно демонстрирует, что массив и адрес — это одно и то же.

Обратите внимание, что для получения адреса массива вам не нужен оператор &, поскольку компилятор считает, что массив и есть адрес.


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

Перевод статьи «C Programming Tips»