Фон

Профессионально я инженер-программист, который в основном занимается разработкой серверных приложений. Мои языки программирования для работы — это в основном Java и Golang, с небольшим количеством Python для написания сценариев. Так что мой опыт в C и C++ весьма ограничен.

Сразу хочу сказать, что это не руководство по программированию на C. Эта статья предназначена исключительно для того, чтобы рассказать о моем опыте программирования на C. Однако я никоим образом не являюсь экспертом в C, поэтому в моих объяснениях могут быть ошибки или ошибки.

Выбор языка программирования

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

Голанг

Golang был моим любимым языком программирования даже для личных проектов. Язык очень простой с очень хорошей производительностью во время выполнения. Также очень легко выполнить кросс-компиляцию для разных архитектур и операционных систем. Однако я решил не использовать Golang для этого проекта по следующим причинам.

Практически не существовало чистых реализаций Golang для библиотеки аудио API. Я искал пакеты Golang для него и нашел пакеты, которые являются привязками CGO к библиотекам C (например, PortAudio). Сами библиотеки C, однако, являются кросс-платформенными библиотеками для предоставления абстракций API-интерфейсам аудио для конкретных платформ (например, PulseAudio в Linux, Core Audio в macOS, ASIO или WDM/KS в Windows), что усложняет программу, если я когда-либо нужно отладить его.

Более того, эти пакеты почти никогда не обновлялись и не пользовались большой популярностью у других разработчиков Golang. Однако сами библиотеки C довольно популярны среди программистов на C. Если бы я решил пойти по этому пути, я бы также написал свой собственный пакет Golang.

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

Джава

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

Во-первых, у него были те же проблемы, что и у Golang: библиотеки Java были просто привязками JNI (Java Native Interface) к тем же библиотекам C, что и библиотеки Golang. И опять же, эти Java-библиотеки либо не пользовались популярностью, либо активно поддерживались.

А еще есть использование ресурсов. Хотя Java в основном подходит для предприятий (поскольку они могут просто масштабировать инфраструктуру, чтобы приспособиться к ней), она не подходит для конечных потребителей. Гораздо меньше, учитывая, что я хотел бы свести ресурсы для моей программы к минимуму.

Node.js

Я включил Node.js сюда только потому, что мне пришло в голову, что я буду использовать Node.js для выполнения этой работы. Однако я уже давно не прикасался к JavaScript (и его расширенным языкам, таким как TypeScript), и переизбыток размера каталога node_modules — просто кошмар для целей моего проекта.

Питон

Я люблю Python по большей части. Это самый простой язык, который можно подобрать практически для чего угодно, будь то студент колледжа, проходящий курс введения в программирование, специалист по обработке данных, обрабатывающий большие объемы данных, или даже создающий большие корпоративные системы. Python очень портативен, а также отлично поддерживает привязки к C-библиотекам.

Python также позволяет писать код как в ООП, так и в процедурном стиле. В последнем случае привязки Python к библиотекам C становятся настолько легкими, без ненужных абстракций, которые в противном случае были бы скрыты в самой привязке.

Для этого проекта у Python есть пакеты, которые обеспечивают привязку к тем же библиотекам C, что и для Golang и Java. За исключением того, что пакеты Python активно поддерживаются и популярны, поэтому проще найти решения в Интернете, если я когда-нибудь обнаружу проблемы с их использованием.

Однако Python — не самый производительный язык программирования. Но в моем случае мне не нужна такая высокая производительность для потоковой передачи звука. С объемом памяти все в порядке, он не такой раздутый, как Java, но и не такой компактный, как Golang или даже C.

Я почти выбрал Python для этого проекта. Но потом я понял, что в какой бы системе ни запускалась моя программа, потребуется и Python, что никогда не будет проблемой для Golang и C. Именно это в конце концов заставило меня переключиться на язык C/C++.

Изучаем язык(и) программирования C

C и C++ (я буду называть их CXX для простоты) были моим окончательным выбором для этого проекта. Сначала было сложно настроить для них набор инструментов. Мне пришлось установить LLVM, CMake и Make (или Ninja в моем случае для Windows). Затем мне нужно было изучить синтаксис CMake, чтобы упростить некоторые вещи, такие как связывание библиотек и поиск включаемых каталогов. Это была дополнительная кривая обучения. Но как только я освоился, это действительно помогло упростить настройку.

Тогда есть проблема с переносимостью. Всегда сложно писать переносимый CXX-код, потому что разные платформы имеют свои собственные функции, макросы и структуры (вот почему POSIX — это прежде всего).

В какой-то момент я решил просто использовать C++, чтобы использовать библиотеки Boost. Но чем больше я писал код на C++, тем больше я его ненавидел. Почему? Потому что это излишне сложно.

На момент написания я использовал стандарт C++ C++17. Когда я копирую пару фрагментов кода из Интернета, иногда в моей среде IDE появляются предупреждения о том, что определенный фрагмент кода, который я вставил, устарел из-за более нового стандарта, который я использую.

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

Не говоря уже о том, что сами библиотеки Boost несколько противоречивы. Я не уверен, соответствуют ли библиотеки версиям SemVer, но (например) код для сокета UDP различается между версиями 1.61.0 и 1.78.0. Это заставляет меня нервничать, если мне когда-нибудь понадобится обновить библиотеки, тогда это в конечном итоге сломает мой код.

Урегулирование с C

В конце концов я просто остановился на C и решил проблемы с переносимостью, которые я когда-либо встречал сам, поскольку отказ от C++ также означал бы отказ от библиотек Boost. Библиотеки, которые я в итоге использовал:

  • PortAudio — для абстрагирования к API аудио для конкретных платформ.
  • Opus — для высококачественного сжатия звука
  • Натрий — для обмена секретными ключами и шифрования

Все эти библиотеки реализованы на C, что значительно упрощает переход с C++ даже без необходимости поиска альтернатив.

Сначала было сложно реализовать все на C. Некоторые вещи, которые мы считали само собой разумеющимися в других языках программирования, просто не существуют в C. Возьмем, к примеру, хеш-карту. Хэш-карта (или ее эквивалент) существует в Go, Java, JavaScript, Python и даже C++. Но в C его нет, и вам нужно либо найти для него библиотеку, либо просто реализовать его самостоятельно. В таком случае я склоняюсь к последнему.

C — простой язык программирования

Возможно, вы слышали это время от времени или даже видели мемы об этом, но это правда. Конечно, в C не так много языковых функций, как в Java или C++, и вы можете в конечном итоге написать лишнюю строку кода для чего-то, что могло бы быть просто одной строкой кода в других языках. Но это значительно упрощает чтение чужого исходного кода.

Возьмем, к примеру, C++ и его огромный набор языковых возможностей. Если вы читаете чужой код, в котором используются определенные языковые функции, вам необходимо понимать, что эти функции делают и для чего они предназначены. Если нет, у вас будут проблемы с пониманием кода.

В C это меньше проблем. Вы даже можете прочитать исходный код ядра Linux и все равно понять, что делает этот код, потому что, опять же, C прост и ясен. Я мог только подумать, что C усложняется из-за использования макросов, но даже тогда его легче понять, чем код C++.

Сначала я сознательно пытался заставить C работать как Java. Но, очевидно, это не работает, поэтому я попытался заставить его работать как Golang, и в определенной степени это сработало. Отличительной особенностью Golang является то, что он пытался быть больше похожим на C, чем на C++, поэтому Golang избегает называться ООП-языком (в Golang вы работаете со структурами, а не с объектами). Конечно, у Golang есть интерфейс и все такое, но это просто более простой подход к тому, что уже можно сделать в C.

Работайте с выделением памяти, а не с объектами

Одно из самых больших препятствий в изучении C — это то, как работают указатели и распределение памяти. А затем вы начинаете читать код, творя чудеса с указателями, например, итерация цикла for только с помощью указателя, что еще больше сбивает вас с толку. Или, может быть, даже функции, которые принимают двойные указатели в качестве параметров, и какие волшебные вещи они с ними делают.

Во-первых, выделение (и освобождение) памяти в C выполняется программистом вручную. Вы определяете, сколько байтов вам нужно для хранения данных, а затем начинаете работать дальше. И тогда само выделение памяти может быть выполнено в стеке или в куче, последнее, что вам нужно отслеживать и освобождать память, когда она больше не используется, чтобы избежать утечки памяти.

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

Указатели не волшебны

После того, как вы выделили немного памяти, вам нужно что-то, что подскажет вам, где получить доступ к памяти. Именно здесь в игру вступают адрес памяти и указатель. Адрес памяти в основном говорит вам, где находится выделение памяти, а указатель просто хранит этот адрес памяти. Когда вам нужно получить доступ к значению, хранящемуся в этой памяти, вам просто нужно разыменовать указатель.

Но что, если я хочу создать функцию, которая принимает указатель и изменяет адрес памяти, хранящийся в этом указателе? Указатель сам по себе фактически выделяет память, что означает, что мы можем делать то же самое, что и с выделением памяти, на которое указывает указатель.

Здесь в игру вступает двойной указатель. Двойной указатель — это в основном указатель, который указывает на другой указатель. Передавая двойной указатель функции, вы можете разыменовать указатель, на который он указывает, и оттуда вы можете изменить адрес памяти указателя. Вы можете спросить, почему бы просто не использовать один указатель. Следующая часть может просто ответить на ваш вопрос.

Каждый параметр функции передается по значению

Возьмем пример. Предположим, у вас есть такая функция.

int add(int a, int b) {
    return a + b;
}

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

Предположим, у вас есть следующий фрагмент кода.

int x = 1, y = 2;
int z = add(x, y);

Когда вы объявляете переменные x и y, вы выделяете память в стеке. А затем, когда вы передадите их в функцию add, функция снова выполнит выделение памяти для параметров a и b. Опять же, это делается в стеке. После этого значения из x и y копируются в a и b, и все это делается автоматически. Вот почему, когда вы изменяете значение a в функции, оно никогда не изменит значение x. По сути, так работает передача по значению.

А что, если мы хотим передать значение, а функция изменит его? Это то, что обычно называют передачей по ссылке. Но то, что на самом деле происходит, по крайней мере, с точки зрения функции, по-прежнему является передачей по значению. Рассмотрим этот фрагмент кода.

void add_p(int a, int b, int *c) {
    *c = a + b;
}
int main() {
    int x = 1, y = 2, z;
    add_p(x, y, &z);
}

Переменные и параметры a, b, x и y почти такие же, как и раньше. Разница будет в локальной переменной z и дополнительном параметре c. Параметр c — это указатель на целочисленное значение где-то в памяти. Поэтому при передаче параметра для него мы используем оператор амперсанда (&) для создания указателя из значения.

Отсюда, как это снова работает в функции, указатель из параметра c выделяется адресом памяти, скопированным из &z. Чтобы изменить значение z, нам нужно разыменовать указатель c и установить новое значение.

Теперь в качестве пищи для размышлений предположим, что я добавил эту строку кода в конец функции add_p.

c = &a;

Как вы думаете, что произойдет?

  1. Значение z становится равным 1 (равно x и a)
  2. Ничего не произошло

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

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

void min_p(int *a, int *b, int **p) {
    *p = *a <= *b ? a : b;
}
int main() {
    int a = 1, b = 2;
    int *cptr = NULL;
    min_p(&a, &b, &cptr);
}

Приведенная выше функция принимает два указателя на целое число и один двойной указатель на целое число. Конечным результатом является то, что указатель cptr будет указывать на адрес памяти a.

Буферы ввода-вывода и массивы — это просто выделение памяти

Что меня больше всего смутило в C, так это то, как работают массивы и буферы. Я склонен видеть фрагменты кода, в которых циклы for или while выполняются для указателя, указывающего на буфер или массив. Оказывается, буфер — это, по сути, просто массив (байтов), а массив — это выделение памяти для хранения нескольких значений.

Сколько байтов памяти выделяется для массива, зависит от типа данных массива и размера массива. Предположим, вы объявляете переменную char[8], это выделит 8 байтов памяти (каждый char занимает байт). И тогда массив на самом деле является указателем, указывающим на выделение памяти. Вот почему вы часто видите, что char[] и char* используются взаимозаменяемо.

Итак, как же работает итерация массива? Если вы используете цикл for, это обычно способ перебора массива.

int values[ARRAY_SIZE];
for (int i = 0; i < ARRAY_SIZE; i++) {
    int value = values[i];
    // do something with value
}

Помните, я сказал, что массив — это всего лишь указатель? Оказывается, эти две строки кода в основном делают одно и то же.

int first_value = values[0];
int first_value_too = *values;

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

int second_value = values[1];
int second_value_too = *(values + 1);

Это снова делает то же самое. Но что немного удивительно, так это то, что вы можете выполнять арифметические операции над самим указателем (без разыменования). То, что делают эти арифметические операции, меняет адрес памяти указателя.

Struct — это, по сути, структура памяти (Duh)

Хотя в C нет наследования (как и в Golang), у нас есть структуры в C. Однако я склонен думать, что композиция намного лучше, чем наследование, и она очень хорошо работает в C. Предположим, у вас есть структуры, подобные этим.

struct base_t {
    int id;
    int state;
};
struct derived_t {
   struct base_t base;
   void *data;
};

Если бы это было ООП, то мы, по сути, делаем производное от base_t под названием derived_t. Однако на самом деле мы делаем композицию, включающую структуру base_t. Структура работает следующим образом: мы определяем структуру для выделения памяти и доступа к ней. И, выполняя композицию, в которой мы помещаем base_t в качестве первого члена derived_t, мы можем преобразовать указатель derived_t в указатель base_t, и он все равно будет нормально работать. Это более или менее похоже на то, как наследование работает в ООП.

Давайте посмотрим на них в действии. Предположим, у нас есть функция, которая принимает указатель на base_t.

void print_base(struct base_t *base) {
    printf("id=%d, state=%d\n", base->id, base->state);
}

Затем вы вызываете функцию следующим образом.

struct base_t base = {.id = 1, .state = 0};
struct derived_t derived = {.base = base};
print_base(&base);
print_base(&derived.base);
print_base((struct base_t *)&derived);

Это будет печатать те же точные результаты. Из-за того, что структура памяти derived_t идентична base_t (до члена data), это работает абсолютно нормально. Зачем вам это делать, спросите вы? Если вы занимались программированием сокетов, вы можете столкнуться с этим фрагментом кода.

struct sockaddr_in saddr;
// Define socket family, host, and port
bind(sock_fd, (struct sockaddr *)&saddr, sizeof(struct sockaddr_in));

Второй параметр bind принимает указатель sockaddr. Но на самом деле мы передаем указатель sockaddr_in, и он все еще работает, это связано с тем, что структура памяти sockaddr_in идентична sockaddr.

Заключительные слова

Хотя мне хотелось написать больше о том, чему я научился при реализации своего проекта на языке C, мне пришлось пока закончить статью здесь. В противном случае я бы просто продолжал писать и в конце концов потерял бы мотивацию публиковать эту статью.

Я никогда не использовал C во время своей карьеры инженера-программиста, и последний раз, когда я использовал C, был, когда я проходил курс введения в программирование еще в колледже. В то время я ненавидел C и считал, что у него действительно высокая кривая обучения, которая может быть полезна для чего угодно, даже для обучения первокурсников колледжа.

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

В итоге мне удалось завершить прототип моего проекта. Мне даже удалось написать кроссплатформенный протокол на основе UDP, чтобы программа работала на нескольких платформах (пока тестировались только Windows и macOS). Если вам интересно это увидеть, вы можете проверить это в моем общедоступном репозитории GitHub.

На данный момент - все. Может быть вторая часть этого, если я узнаю что-то еще и захочу написать об этом.