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

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

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

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

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

typedef char (*PFC)();

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

PFC z;

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

char a() {
      return 'a';
}

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

z = a;

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

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

printf("I am %c\n", z());

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

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

int vararg(int arg_count, ...) {}

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

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

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

va_list argp;

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

va_start(argp, arg_count);

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

for (i = 0; i < arg_count; i++) {
      j = va_arg(argp, int);
      t += j;
}

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

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

va_end(argp);

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

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

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

int moving;
int decelerating;
int accelerating;

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

#define MOVING (1 << 1)
#define DECELERATING (1 << 2)
#define ACCELERATING (1 << 3)
int state;

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

state |= MOVING;
state &= ~MOVING;

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

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

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

value |= 1 << bit

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

value &= ~(1 << bit);

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

r = value & (1 << bit);

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

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

FILE *f = 0;

int short_circuit_ok() {
      int t;

      t = (int)f && feof(f);
      return t;
}

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

int short_circuit_bad() {
      int t;
      t = (int)f & feof(f);
      return t;
}

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


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

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

< тестовое выражение > ? < если верно, выполнить это > : < иначе выполнить это >

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

if (items > 0) {
      t = items;
} else {
      t = -items;
}

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

t = items > 0 ? items : -items;

2

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

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

if (items == 1) {
      printf("there is %d item\n", t);
} else {
      printf("there are %d items\n", t);
}

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

printf("there %s %d item%s", t == 1 ? "is" : "are", t, t == 1 ? "\n" : "s\n");

6. Стек

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

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

#define STACK_SIZE 2
static int _stack[STACK_SIZE];
static int* _sp = _stack;

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

int push(int value) {
      int count;count = _sp - _stack;
      if (count >= STACK_SIZE) {
            count = -1;
      } else {
            *_sp++ = value;
            count += 1;
      }
      return count;
}

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

int pop(int* value) {
      int count; count = _sp - _stack;
      if (count == 0) {
            count = -1;
      } else {
            *value = *--_sp;
      count -= 1;
      }
      return count;
}

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

void test_stack() {
      int i, r, v;
      for (i = 0; i < 4; i++) {
            v = i + 10;
            r = push(v);
            printf("push returned %d; v was %d\n", r, v);
      }
      for (i = 0; i < 4; i++) {
            v = 0;
            r = pop(&v);
            printf("pop returned %d; v was %d\n", r, v);
      }
}

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

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

void copy1(void *src, void *dst, int n) {
      memcpy(dst, src, n);
}

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

void copy2(void *src, void *dst, int n) {
	int i;
	char *p, *q;
        for (i = 0, p = src, q = dst; i < n; i++) {
		*p++ = *q++;
	}
}

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

void copy3(void *src, void *dst, int n) {
	int i;
	int *p, *q;
        for (i = 0, p = (int*)src, q = (int*)dst; i < n / 4; i++) {
	        *p++ = *q++;
	}
}

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


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

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

3

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

typedef int T;
typedef float F;
const int T_SIZE = sizeof(T);

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

#include "header1.h"
typedef struct {
	T t;
	F f;
} U;
const int U_SIZE = sizeof(U);

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

#include "header1.h"
#include "header2.h"

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

#ifndef HEADER1_H
#define HEADER1_H
typedef int T;
typedef float F;
const int T_SIZE = sizeof(T);
#endif

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


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

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

  1. Скобки нужно использовать для изменения порядка выполнения операторов. Например, 3 * (4 + 3) — не то же самое, что 3 * 4 + 3 .
  2. Скобки можно использовать для улучшения читаемости. Здесь они, очевидно, не нужны:
    t = items > 0 ? items : -items;
    

    Приоритет оператора || ниже, чем < и >. Однако, в этом случае скобки точно не будут лишними:

    (x > 0) || (x < 100 & y > 10) || (y < 0)
  3. Скобки стоит использовать в макросах:
    #define MYCONST (4 + 3)
    

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

    3 * MYCONST
    

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

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

return (x + y);

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

return x + y;

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

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

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

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

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

static int _x[4];

test_array_as_address() {
	int i;

	for (i = 0; i < 4; i++) {
		_x[i] = (int) (_x + i);
	}

	for (i = 0; i < 4; i++) {
		printf("%x:%x:%x\n", _x + i, _x[i], *(_x + i));
	}

}

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

_x[i] = (int) (_x + i);

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

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

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


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

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