Пиши на C как джентльмен

26487715e69445adb1c631dc801830d5.jpg
«Code Monkey like Fritos
Code Monkey like Tab and Mountain Dew
Code Monkey very simple man
With big warm fuzzy secret heart:
Code Monkey like you
Code Monkey like you»

 — Jonathan Coulton — Code Monkey

Я думаю, многим знакома эта шикарная песня Jonathan Coulton’а, и эта жизненная ситуация, когда «Rob say Code Monkey very diligent», но «his output stink» и «his code not 'functional' or 'elegant'».

Язык Си, подаривший нам столько полезного софта, потихоньку был вытеснен из десктопа и энтерпрайза такими высокоуровневыми гигантами как Java и C# и занял нишу системного программирования. И все бы хорошо, но системщики — очень отбитые своеобразные ребята. Задачи, которые порой возникают перед ними даже своей формулировкой способны вогнать в ужас простых смертных. Собственно говоря, так же, как и некоторые решения.

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

Пункты будут располагаться от самых фундаментальных и очевидных (ориентированных на новичков в языке Си) до самых специфичных, но полезных. Если чувствуете, что вы это знаете — листайте дальше.

Практика I: Соблюдайте единый Code Style и фундаментальные принципы «хорошего тона»


Функция принимает в качестве аргумента переменную INPUT, парсит её в массив IncomingValues и возвращает result_to_return? Отставить быдлокод!
То, что в первую очередь выдает новичка — несоблюдение единого стиля написания кода в рамках конкретного приложения. Следом идет игнорирование правил «хорошего тона».
Вот несколько самых распространенных рекомендаций к оформлению кода на Си:
  • Названия макросов и макрофункций пишутся капсом, слова в названиях отделяются друг от друга нижним подчеркиванием.
    #define MAX_ARRAY_SIZE    32
    #deifne INCORRECT_VALUE   -1
    #deifne IPC_FIND_NODE(x)  ipc_find_node(config.x)
    

  • Названия переменных записываются в нижнем регистре, а слова в названиях отделяются нижним подчеркиванием
    int my_int_variable = 0;
    char *hello_str = "hello_habrahabr";
    pid_t current_pid = fork();
    Вообще, этот пункт спорный. Мне доводилось видеть проекты, где имена переменных и функций пишутся в pascalCase и CamelCase соответственно.
  • Названия библиотечных функций общего пользования пишутся одним словом, иногда сокращенным, но это слово передает суть функции — что она должна делать.
    Кстати, рекомендую взять за привычку писать код так, чтобы одна функция делала только одну вещь. Именно это должно отразиться в названии.
    То же можно сказать и про переменные — никаких a, b, c — в названии должен быть отражен смысл (итераторы не в счет). Самодокументируемый код — очень хорошая практика.
  • Специализированные функции (которые вызываются в пределах работы внутри какого-то специфичного контекста) лучше называть так, чтобы было определенно ясно, что делает эта функция.
    Как правило, можно выбрать между стилем написания названия: CamelCase и under_score, тут уже зависит от вас.
    /* пример функции общего пользования */
    static void dgtprint(char *str) {
         int i;
         for (i = 0; i < strlen(str); i++) {
             if (isdigit(str[i]))
                 printf("%c", str[i]);
             else
                 print("_");
         }
    }
    
    /* пример функции для работы со специфичным контекстом */
    /* CamelCase */
    void EnableAllVlans(struct vlan_cfg *vp) {
        int i;
        for (i = 0; i < VLAN_COUNT; i++) {
            EnableVlanByProto(vp.vlan[i]);
        }
    }
    
    /* under_score */
    void enable_all_vlans(struct vlan_cfg *vp) {
        int i;
        for (i = 0; i < VLAN_COUNT; i++) {
            enable_vlan_by_proto(vp.vlan[i]);
        }
    }

  • i, j, k — стандартные названия для итераторов цикла
    int array[MAX_ARRAY_SIZE] = arrinit();
    register int i, j, k;
    for (i = 0; i < MAX_ARRAY_SIZE; i++) 
        for (j = 0; j < MAX_ARRAY_SIZE; j++)
            for (k = MAX_ARRAY_SIZE; k >= 0; k--)
                dosmthng(i, j, k, array[i]);
  • Соблюдайте однородность переноса скобок
    if (condition) { dosmthng(); } else
    {
        dont_do_something();
    } /* Не делайте так */
    
    if (condition) {
        dosmthng(); 
    } else {
        dont_do_something();
    } /* Гораздо правильнее будет следовать одному правилу переноса скобок, как тут */
    
    if (condition) 
    {
        dosmthng(); 
    } 
    else
    {
        dont_do_something();
    } /* Или как тут */
    
    /* Ну, или как тут, но это уже совсем экзотика */
    if (condition) { dosmthng(); } else { dont_do_something(); } 
  • Объявляйте переменные в начале функции. Если это глобальные переменные, то в начале файла.
    По возможности инициализируйте переменные при объявлении. Численные с помощью нуля, указатели — NULL:
    int counter = 0, start_position = 0, unknown_position = 0;
    struct dhcp_header * dhcp = NULL, * dhcp_temp = NULL;
    char input_string[32] = { 0 };
    Ну оставили мы переменные неинициализированными, и что?
    А то. Если смотреть их (до инициализации) в отладке (в том же gdb), там будет лежать мусор. Это нередко сбивает с толку (особенно, если мусор «похож на правду»). Про указатели я вообще молчу.
  • Пишите комментарии с умом.
    Не надо комментировать каждую строчку кода — если вы пишите самодокументируемый код, большая часть его будет простой для понимания.

    Оптимальное решение — писать описания функций, если из аргументов и названия сложно понять весь её функционал. Для переменных — правила те же, в пояснении нуждаются только какие-то нелинейный вещи, где одного названия мало.

    На самом деле, в вопросе документации у вас есть полная свобода действий — надо лишь следить, чтобы комментариев было не много, но достаточно, чтобы человек, видящий ваш код в первый раз, не задавал вопросов.

    /* Возвращает 1, если параметры, связанные с модемным 
     * соединением изменились, и 0, если нет. 
     */
    static int CheckModemConnection()
    {
      int i = 0;
      /* Проверка сети отдельно - меняется чаще всего */
      if (CHECK_CFG_STR(Network.LanIpAddress) || CHECK_CFG_STR(Network.LanNetmask))
        return 1;
    
      for(i = 0; i < MAX_MODEM_IDX; i++)
      {
        if (CHECK_CFG_INT(Modems.Modem[i].Proto) || CHECK_CFG_INT(Modems.Modem[i].MTU) ||
          CHECK_CFG_STR(Modems.Modem[i].Username) || CHECK_CFG_STR(Modems.Modem[i].Password) ||
          CHECK_CFG_STR(Modems.Modem[i].Number) || CHECK_CFG_STR(Modems.Modem[i].AdditionalParams) ||
          CHECK_CFG_STR(Modems.Modem[i].PIN) || CHECK_CFG_STR(Modems.Modem[i].MRU) || 
          CHECK_CFG_STR(Modems.Modem[i].PppoeIdle) || CHECK_CFG_STR(Modems.Modem[i].USBPort) ||
          CHECK_CFG_STR(Reservation.Prefer) || CHECK_CFG_STR(Modems.Modem[i].PppoeConnectType) || 
          CHECK_CFG_INT(Modems.Mode) || CHECK_CFG_INT(Aggregation.usb1) || CHECK_CFG_INT(Aggregation.usb2))
          return 1;
      }
      return 0;
    }

    Если вы постоянно работаете с трекерами (вроде RedMine), то при внесении правок в код можно указать номер задачи, в рамках которой эти правки были внесены. Если у кого-то при просмотре кода возникнет вопрос а-ля «Зачем тут этот функционал?», ему не придется далеко ходить. В нашей компании еще пишут фамилию программиста, чтобы если что знать, к кому идти с расспросами.
    /* Muraviyov: #66770 */
P.S. Для тех кто устраивается на работу: так же не следует забывать, что в каждой компании, как правило, используется свой Code Style, и ему нужно следовать. В противном сулучае можно получить как минимум укоризненные взгляды товарищей-разрабов или втык от начальства.

Практика II: Оптимизируйте структуру вашего проекта


Если у вас в проекте несколько файлов — имеет смысл хорошо подумать над структурой проекта.
Каждый проект уникален, но, тем не менее, существует ряд рекомендаций, которые помогут удобно структурировать проект:
  1. Называйте файлы так, чтобы всем было ясно, какой файл за что отвечает.
    Не следует называть файлы file1.c, mySUPER_COOL_header.h и т.д.
    main.c — для файла с точкой входа, graph_const.h — для заголовочника с графическими константами будет в самый раз.
  2. Храните заголовочники в директории include.
    Рассмотрим пример:
    • project/
      • common.c
      • common.h
      • main.c
      • network.h
      • networking.c
      • networking_v6.c
      • packet.c
      • packet.h
      • Makefile

    В принципе, проект как проект. Но давайте на секунду представим, что у нас не 9 файлов, а, скажем, 39. Что-то быстро найти будет проблематично. Да, в консоли — пара пустяков, но что если человек работает с GUI, или, что еще хуже, пытается найти файл в Github/Gitlab/Bitbucket?
    Если он точно не знает, какой файл ему нужен? Можно сберечь много нервов, если сделать так:
    • project/
      • include/
        • common.h
        • network.h
        • packet.h

      • common.c
      • main.c
      • networking.c
      • networking_v6.c
      • packet.c
      • Makefile

    Не забываем, что путь к директории include следует указать в параметрах сборки. Вот примерчик для простого Makefile (include в той же директории, что и Makefile):
    @$(CC) $(OBJS) -o networkd -L$(ROMFS)/lib -linteraction -Wall -lpthread -I ./include

  3. Логически группируйте .c файлы в папки.
    Если у вас игра, в которой есть файлы, отвечающие за движок/звук/графику — будет удобно раскидать их по папкам. Звук, графику и движок — отдельно друг от друга.
  4. Дополнение к предыдущему пункту — файлы сборки круто размещать в каждой из таких отдельных директорий, и просто вызывать их из файла сборки в корневой директории. В таком случае Makefile в корневой директории будет выглядеть примерно так:
    .PHONY clean build
    build:
        cd sound/ && make clean && make 
        cd graphics/ && make clean && make
        cd engine/ && make clean && make
    sound:
        cd sound/ && make clean && make
    graphics:
        cd graphics/ && make clean && make
    engine:
        cd engine/ && make clean && make
    clean:
        cd sound/ && make clean
        cd engine/ && make clean
        cd greaphics/ && make clean

Практика III: Используйте враппер-функции для обработки возвращаемых значений


Враппер-функция (функция-обертка) в языке Си используется как функция со встроенной обработкой возвращаемого значения. Как правило, в случае ошибки в работе функции, возвращаемое значение вам об этом скажет, а глобальная переменная errno примет в себя код ошибки.

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

Но обрабатывать значение от каждой функции в коде — такое себе решение. Тут же упадет читаемость, и объем (+ избыточность) кода увеличится в пару раз.

Тут и помогают врапперы. Рассмотрим первый пример — безопасный код без врапперов:

int sock_one = 0, sock_two = 0, sock_three = 0;
/* операция сравнения имеет больший приоритет, чем операция присваивания, 
 * поэтому присваивание выполняется в скобках
 */
if ((socket_one = socket(AF_INET , SOCK_STREAM , 0)) <= 0) { 
    perror("socket one");
    exit(EXIT_ERROR_CODE);
}
if ((socket_two = socket(AF_INET , SOCK_DGRAM , 0)) <= 0) { 
    perror("socket two");
    exit(EXIT_ERROR_CODE);
}
if ((socket_three = socket(PF_INET , SOCK_RAW , 0)) <= 0) { 
    perror("socket three");
    exit(EXIT_ERROR_CODE);
} 

Ну, такое себе, не правда ли? Теперь попробуем с обертками.
/* Где-то в коде... */
int Socket(int domain, int type, int proto) {
    int desk = socket(domain, type, proto);
    if (desk <= 0) {
        perror("socket");
        exit(EXIT_ERROR_CODE);
    }
    return desk;
}
/* ......... n строчек спустя - наш предыдущий пример ......... */
int socket_one = 0, socket_two = 0, soket_three = 0;
socket_one = Socket(AF_INET , SOCK_STREAM , 0);
socket_two = Socket(AF_INET , SOCK_DGRAM , 0);
socket_three = Socket(PF_INET , SOCK_RAW , 0);

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

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

Практика IV: Используйте keywords как профи


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

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

  • register — дает компилятору указание по возможности хранить переменную в регистрах процессора, а не в оперативной памяти. Использование модификатора register при объявлении переменной-итератора цикла с небольшим телом может повысить скорость работы всего цикла в несколько раз.
    register byte i = 0;
    for (i; i < 256; i++)
        check_value(i);

  • restrict — при объявлении указателя дает компилятору гарантию (вы, как программист, гарантируете), что ни один указатель не будет указывать на область памяти, на которую указывает целевой указатель. Профит этого модификатора в том, что компилятору не придется проверять, не указывает ли какой-то еще указатель на целевой блок памяти. Если у вас внутри функции несколько указателей одного типа — возможно, он вам пригодится.
    void updatePtrs(size_t *restrict ptrA, size_t *restrict ptrB, size_t *restrict val);
    

  • volatile — указывает компилятору, что переменная может быть изменена неявным для него образом. Даже если компилятор пометит код, зависимый от волатильной переменной, как dead code (код, который никогда не будет выполнен), он не будет выброшен, и в рантайме выполнится в полном объеме.
    int var = 1;
    if (!var)             /* Эти 2 строчки будут отброшены компилятором */
        dosmthng();   
    
    volatile int var = 1;
    if (!var)            /* А вот эти  - нет */
        dosmthng();  

И это только вершина айсберга. Различных модификаторов и ключевых слов — куча.

Практика V: Не доверяйте себе. Доверяйте valgrind.


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

Valgrind — программа, которая создана для того, чтобы помочь программисту выявить утечки памяти и ошибки контекста. Не буду вдаваться в подробности, скажу лишь, что даже в небольших программах он нередко находит косяки, которые совсем не очевидны для большинства программистов, но, тем не менее, в эксплуатации могут повлечь за собой большие проблемы. За всем не уследишь.
+ у нее есть и другой полезный функционал.

Более подробно о нем можно узнать тут.

Практика VI: Помогайте тем, кто хочет улучшить ваш софт


Довольно много раз я встречал моменты реализации, когда тип задачи feature в трекере рядом с названием «проблемной» программы вызывал сознание того, что на носу тонна работы.

Пример будет взят из исходников busybox 1.21. Для тех кто не знает, что такое busybox, можете посмотреть эту вики-статью.

В состав busybox входит крошечный DHCP-клиент — udhcp. В один прекрасный день перед мной встала задача — расширить его функционал, сделав его способным к передаче/приему пакетов длиной больше 576 байт. Максимальный размер DHCP-пакета в этом клиенте был именно 576 байт (это стандарт mtu для сетей на X.25). Такой небольшой размер — прямое противоречие RFC с описанной в нем 57й опцией.

Но не будем углубляться в подробности, почему создатели busybox так согрешили, посмотрим уже пример:

/* 1й момент */
enum {
	IP_UDP_DHCP_SIZE = sizeof(struct ip_udp_dhcp_packet) - CONFIG_UDHCPC_SLACK_FOR_BUGGY_SERVERS,
	UDP_DHCP_SIZE    = sizeof(struct udp_dhcp_packet) - CONFIG_UDHCPC_SLACK_FOR_BUGGY_SERVERS,
	DHCP_SIZE        = sizeof(struct dhcp_packet) - CONFIG_UDHCPC_SLACK_FOR_BUGGY_SERVERS,
};

Собственно, сами структуры пакетов:
struct dhcp_packet {
	uint8_t op;      /* BOOTREQUEST or BOOTREPLY */
	uint8_t htype;   /* hardware address type. 1 = 10mb ethernet */
	uint8_t hlen;    /* hardware address length */
	uint8_t hops;    /* used by relay agents only */
	uint32_t xid;    /* unique id */
	uint16_t secs;   /* elapsed since client began acquisition/renewal */
	uint16_t flags;  /* only one flag so far: */
	uint32_t ciaddr; /* client IP (if client is in BOUND, RENEW or REBINDING state) */
	uint32_t yiaddr; /* 'your' (client) IP address */
	/* IP address of next server to use in bootstrap, returned in DHCPOFFER, DHCPACK by server */
	uint32_t siaddr_nip;
	uint32_t gateway_nip; /* relay agent IP address */
	uint8_t chaddr[16];   /* link-layer client hardware address (MAC) */
	uint8_t sname[64];    /* server host name (ASCIZ) */
	uint8_t file[128];    /* boot file name (ASCIZ) */
	uint32_t cookie;      /* fixed first four option bytes (99,130,83,99 dec) */
	uint8_t options[DHCP_OPTIONS_BUFSIZE + CONFIG_UDHCPC_SLACK_FOR_BUGGY_SERVERS];
} PACKED;

struct ip_udp_dhcp_packet {
	struct iphdr ip;
	struct udphdr udp;
	struct dhcp_packet data;
} PACKED;

struct udp_dhcp_packet {
	struct udphdr udp;
	struct dhcp_packet data;
} PACKED;
Теперь подробнее.

Первый момент

Константы, обозначающие размер пакета — напрямую привязаны к sizeof от неизменной по размеру структуры (в ней нет указателей — только стек, только хардкор). О чем это говорит? А о том, что если мы захотим сделать реализацию, в которой размер пакета будет вычисляться динамически (из соображений этики и следования стандартам, только такая реализация и имеет право на жизнь), нам придется:
  1. Изменить структуру dhcp-пакета, буффер опций (именно он влияет на размер) сделать указателем, чтобы в дальнейшем выделять под него память.
  2. Отказаться от этих констант и высчитывать размер пакетов прямо в коде. А мест, где используются эти константы ох как немало.
  3. Во всех местах, где инициализируется dhcp-пакет, нам придется делать выделение и последующее освобождение памяти. А мест этих много, код busybox’а отнюдь не простой, и для того, чтобы грамотно это реализовать придется убить кучу времени только на то, чтобы разобраться, где и как эти пакеты инициализируются, и насколько долго живут.

Но, знаете, это так, цветочки. Последствия несколько беспечной реализации. Теперь ягодки.

В том же файле можно найти такие строки:

/* 2й момент */
/* Let's see whether compiler understood us right */
struct BUG_bad_sizeof_struct_ip_udp_dhcp_packet {
	char c[IP_UDP_DHCP_SIZE == 576 ? 1 : -1];
};

Второй момент:

char c[IP_UDP_DHCP_SIZE == 576 ? 1 : -1];

Тернарный оператор, при инициализации массива с длиной 1, в случае, если константа IP_UDP_DHCP_SIZE равна 576, и -1, если длина отличается. Лично у меня, когда я это увидел, повис вопрос: «Чеееееееее?».

То есть, если нам надо просто увеличить размер пакета (через увеличение размера буфера опций), следуя текущей реализации (скажем, адаптировать под mtu для Ethernet II), то при компиляции у нас будет что-то вроде:

test.c:7:7: error: size of array «c» is negative
char c[IP_UDP_DHCP_SIZE == 576? 1: -1];

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

Это — пример того, как делать не стоит. Никогда. Иначе случится насилие. Рано или поздно.

На самом деле, код busybox очень эллегантен, пусть и совсем не прост. Всем, кто хочет взглянуть на язык си под другим углом — рекомендую ознакомиться с исходниками. Ситуация с udhcpc — скорее исключение из правил.Пиши код так, чтобы те, кто будет его сопровождать любили тебя, а не ненавидели.
Сложная гибкая реализация гораздо лучше простого костыля.
Описывай интерфейсы доступа, комментируй проблемные моменты. Не делай констант, от изменения которых придется переписывать весь код. Не допускай утечек памяти. Следи за безопасностью и отказоустойчивостью кода.
Пиши на Си как джентльмен.
Удачи, Хабр!

Комментарии (0)

© Habrahabr.ru