Международная конференция разработчиков
и пользователей свободного программного обеспечения

Между «до» и «ля»: какие прелести сохранил Си в современном мире?

Vadim Zhukov, Moscow, Russian Federation

LVEE 2018

An overview of applicability of C language in modern world, demonstrating its weak and strong sides.

C появился в 1969 году (, и на тот момент считался высокоуровневым языком программирования. В настоящее же время существует огромное количество языков программирования, куда более удобных, предсказуемых и весьма производительных. Что же позволяет данному языку “https://tproger.ru/articles/github-top-10-languages-2017/(оставаться востребованным)”? И насколько сложным в действительности является программирование на современном С? — на эти вопросы мы и будем отвечать. Здесь не будет подробного разбора отличий от C++ и других без лишних усилий доступных вещей.

Язык программирования не мог бы оставаться столько лет актуальным без развития. И язык действительно развивается, в данное время — вначале под эгидой ANSI, а сейчас, как и C++ (но другой рабочей группой), под эгидой ISO.

Некоторые примеры полезной эволюции языка:

  • имена аргументов в объявлениях функций
  • типы void и void*
  • типы size_t, ptrdiff_t, (u)intN_t и т.д.
  • объявление переменных посреди кода
  • выкидывание изначально некорректных концепций, например, gets()
  • безымянные struct и union
  • поддержка Unicode
  • поддержка многопоточности

Не очень удачные решения:

  • strict aliasing
  • Exit() и quickexit()

Откровенно слабые стороны C:

  • rand()+srand()
  • недостаточный контроль обращений к памяти в динамических буферах

Странности языка C с точки зрения прикладного программиста:

  • беззнаковые целочисленные типы — необходимы для работы с памятью (как иначе обратиться к адресу 0×8000 на 16-битной системе?).
  • отсутствие реализаций «из коробки» типовых структур таких как словари и деревья — разные решения могут давать принципиально разную производительность и задавать разные требования (например: служебные поля списка/дерева хранятся в самих структурах или за их пределами?), стандартными попросту мало кто будет пользоваться.
  • нет наследования — в системном программировании оно нужно только для данных, поэтому гарантированное «первый член структуры находится по тому же адресу, что и структура» перевешивает плюсы виртуальных вызовов (без которых о наследовании говорить не приходится в принципе).

Мифы и правда о современном C:

  • C пригоден для написания прикладных приложений — миф. Если только не считать прикладным приложением RTSP-сервер для бюджетного одноплатника.
  • Используя указатели, легко ошибиться — с указателями можно, действительно, делать что угодно, но современные компиляторы предупредят в большинстве случаев. Тем не менее, действительно, самые частые ошибки — выход за пределы массива и использование освобождённой памяти — связаны именно с использованием указателей.
  • goto, это же очень плохо? — нет, это, как минимум, альтернатива обработке исключений, пример будет дальше.
  • В нём есть void*, и это ужасно — в других языках изобретают костыли вроде Object, ровно для того же самого, так что не ужаснее прочих. Что же касается риска получить из void* не то, что ожидается, то он сильно переоценён, на практике никто не кладёт в одном и том же коде в одно и то же void*-поле указатели на несовместимые структуры.
  • В C нельзя скрыть детали реализации — можно, и это повсеместно используется: достаточно объявить (но не определить) структуру и далее пользоваться указателями на неё.
  • Нужно постоянно следить за освобождением памяти — за памятью нужно следить везде. Забытая ссылка в языке с подсчётом ссылок на объекты точно так же заставит память утечь, garbage collector тоже никаких гарантий не даст. Использование же простейших, не зависящих от языка, правил позволяет значительно минимизировать количество утечек. Вот найти источник утечки — это в C сложнее, чем в Java, да — средства вроде valgrind работают не везде и не всегда.
  • В C нельзя складывать строки — синтаксиса специального нет, есть чересчур опасная strcat(); есть более вменяемые strlcat() и asprintf(), но они вне стандарта языка.
  • C полностью перекрывается C++ — помимо того, что есть вещи, которые можно делать в C, нельзя в C++, как то: приравнивать (без явного приведения типов) void* к другим указателям, многосимвольные константные литералы, restrict и так далее, — есть и целый ряд не совместимых решений, например, inline-функции.
  • Макросы суть зло — как и почти всё остальное в Си, они позволяют прострелить себе и окружающим ноги самыми разными способами, но есть best practicies, от этого спасающие, пример ниже.
  • C это сплошные переполнения буфера — современные компиляторы давным-давно умеют как добавлять средства детектирования таких ситуаций, так и предупреждать о заведомо опасных конструкциях, поэтому подавляющее большинство проблем этого класса отлавливаются в ходе разработки. Средства детектирования можно обойти, конечно, но для этого требуется найти особую утечку данных, впрочем, это уже отдельный разговор.
  • C не нужен, когда есть Rust, — написать компилятор ANSI C может практически любой вменяемый студент технического ВУЗа, а вот с Rust всё сложнее. Пока есть заметная потребность в написании компиляторов (или хотя бы бэкендов) для новых платформ, C будет выигрывать у Rust, по крайней мере, в ближайшие лет пять (срок получения высшего образования) точно.

Примеры изящных решений, специфичных для C:

1. Код с обработкой ошибок и goto:

int
open_server_socket(const struct srvconf *conf) {
    int sock;

    if ((sock = socket(conf->af, SOCK_DGRAM|SOCK_CLOEXEC, 0)) == -1)
        return -1;
    if (setsockopt(sock, SOL_SOCKET, SO_RCVBUF, conf->rcvsize, sizeof(conf->rcvsize)) == -1)
        goto fail;
    if (conf->rtable >= 0) {
        if (setsockopt(sock, SOL_SOCKET, SO_RTABLE, conf->rtable, sizeof(conf->rtable)) == -1)
            goto fail;
    }
    if (setsockopt(sock, SOL_SOCKET, SO_SNDBUF, conf->rcvsize, sizeof(conf->rcvsize)) == -1)
        goto fail;
    if (bind(sock, (const struct sockaddr *)conf->addr, conf->addrlen) == -1)
        goto fail;
    if (listen(sock, conf->connqlen) == -1)
        goto fail;
    return sock;

fail:
    warn("%s", __func__);
    close(sock);
    return -1;
}

2. Эффективный (деструктивный) разбор строки без выделения памяти:

// RFC 3264
// m=video 29034 RTP/AVP 96
// a=rtpmap:96 H.264/90000

struct sdp_line {
    char     op;
    char    *name;  // не-NULL для атрибутов
    char    *value;
};

int
sdp_parse_line(char *line, struct sdp_line *sdpl) {
    char    *colon;

    if (line[0] == '\0' || line[1] != '=')
        goto inval;
    sdpl->op = line[0];
    line += 2;
    if (sdpl->op == 'a') {
        if ((colon = strchr(line, ':')) == NULL || colon == line)
            goto inval;
        *colon = '\0';
        sdpl->name = line;
        sdpl->value = colon + 1;
    } else {
        sdpl->name = NULL;
        sdpl->value = line;
    }
    return 0;

inval:
	errno = EINVAL;
	return -1;
}

3. Пример макросов, предназначенных для работы с передаваемыми по IPC сообщениями, из реального кода:

#define imsg_get_plain(p, left, v) \
	do { \
		if ((left) < sizeof((v))) \
			fatalx("%s: short imsg read of %s", __func__, #v); \
		memcpy(&(v), (p), sizeof((v))); \
		(p) += sizeof((v)); \
		(left) -= sizeof((v)); \
	} while(0)

#define imsg_put_plain(p, left, v) \
	do { \
		if ((left) < sizeof((v))) \
			fatalx("%s: short imsg write of %s", __func__, #v); \
		memcpy((p), &(v), sizeof((v))); \
		(p) += sizeof((v)); \
		(left) -= sizeof((v)); \
	} while(0)

// примеры использования:
imsg_put_plain(buf, buflen, frag->fr_id);
imsg_put_plain(buf, buflen, frag->fr_from);
imsg_put_plain(buf, buflen, frag->fr_till);
imsg_put_plain(buf, buflen, frag->fr_nframes);
imsg_put_plain(buf, buflen, frag->fr_size);
// ...
imsg_get_plain(buf, buflen, frag->fr_id);
imsg_get_plain(buf, buflen, frag->fr_from);
imsg_get_plain(buf, buflen, frag->fr_till);
imsg_get_plain(buf, buflen, frag->fr_nframes);
imsg_get_plain(buf, buflen, frag->fr_size);

Abstract licensed under Creative Commons Attribution-ShareAlike 3.0 license

Назад