Привет, Хабр! Недавно я задумался: Python — не единственный инструмент, которым я хочу оперировать в своих инструментах. Python, понятно, легко освоить и он применяется везде, но язык-то не идеальный! Ресурсов требует много, да и время выполнения не ахти, а учитывая нынешние темные времена... Мне нужно что-то получше. В общем, тут я вздумал попробовать Си.

Как Си спас инфраструктуру человечества и сделает это еще раз

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

Родился этот язык в 1972 году, когда о таких вещах, как веб-разработка, и речи не шло, а игры тогда были вообще без кода, исключительно аппаратные системы! (Например, игра Pong для компьютера Atari 1972 года)

Спасибо автору книги "Python для детей и родителей" Брайсону Пэйну за экспозицию
Спасибо автору книги "Python для детей и родителей" Брайсону Пэйну за экспозицию

После своего появления Си стал началом новой эпохи разработки программного обеспечения: пока ассемблер был близок к машинному коду, а Бейсик тогда был неким аналогом сегодняшнего Python, Си знатно задрал лапку под древом разработки инфраструктуры.

Влияние Си на ассемблер:

  • Ассемблер не был поглощен Си, но потребность в нем заметно снизилась, а его использование резко сократилось. До этого именно на нем писались многие системные программы (и части ОС тоже);

  • Когда Си предложил все то же самое, но лучше, он начал использоваться разве что там, где Си не справлялся: обработка аппаратных прерываний, работа с привилегированными инструкциями процессора, оптимизация критических мест, и так далее;

  • Стоит также отметить, что большинство компиляторов Си, такие как GCC или Clang, поддерживают ассемблер, вшитый в код Си. Пример синтаксиса GCC: asm("movl %eax, %ebx");

Влияние Си на Бейсик:

  • Бейсик был подвержен влиянию Си куда сильнее. Ранние версии Бейсика (особенно интерпретируемые) подвергались крити��е из-за ряда проблем: обилие GOTO (спагетти-код передает привет), слабую модульность, ограниченные возможности работы с данными...

  • Под влиянием Си Бейсик преобразился (стал снова великим). В нем появились процедуры и функции, как в Си, блочная структура кода (BEGIN/END, SUB/FUNCTION), локальные переменные и структуры данных (как struct в Си);

  • Большинство версий Бейсика начали мигрировать в компилируемый формат (например, QuickBASIC). Код начал оптимизироваться на уровне компилятора, а еще появилась возможность создавать исполняемые программы (.exe-файлы);

  • Бейсик даже начал потакать Си: в некоторых версиях (FreeBASIC, например) он начал поддерживать интеграцию .h-файлов (заголовочные файлы Си) и компиляцию кода в тот же формат, что и Си.

Время лихое, но для умелых золотое

Перенесемся в современность, в которой даже в той же веб-разработке преимущественно сидят Python (FastAPI) и JS — интерпретируемые ЯП, разработка на которых легкая, но при этом инференс довольно ресурсозатратен.

В данный момент, принимая во внимание суровые реалии, только слепоглухонемая бабка из глубин Сибири не слышала о всемирном кризисе ОЗУ, виной которому наш любимый (нет) нейрослоп, который мы каждый день видим в любом соцсети, даже порой на этом же Хабре.

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

Ради этого мы пожертвовали компьютерами
Ради этого мы пожертвовали компьютерами

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

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

На нем построено множество современных ЯП (Python, C++, Rust). В сотни раз быстрее Python и поедает во столько же раз меньше ресурсов. Любимец микроконтроллеров, ОС и прочих специфичных разновидностей ПО. Си — спаситель человечества.

Взято с https://m.vk.com/yaprogrammer?from=groups
Взято с https://m.vk.com/yaprogrammer?from=groups

Мой личный опыт с Си

Найдя на Степике бесплатный курс по Си и скачав C××droid из Google Play (уровень подготовки — бог), я сразу же побежал писать следующий код:

int main()
{
  printf("Hello, world!")
}

...и сразу же с порога получил ошибку. Даже три сразу.

  • Во-первых, я забыл заголовок #include <stdio.h>. Она содержит самые базовые функции ввода и вывода;

  • Во-вторых, после каждой строки кода должна быть точка с запятой (";"), мол, "начало мысли" -> "конец мысли". Как в JS, но тут это обязательно;

  • В-третьих, желательно в функции main() в конце приписывать return 0;. Ошибка более стилистическая для новых компиляторов, но если этого не сделать, то в некоторых случаях вы даже не будете знать, когда программа завершится. Возврат нуля здесь является чем-то вроде выражения "уйти с миром".

После радушных объятий компилятора и анализа своих ошибок мой код выглядел так:

#include <stdio.h>

int main()
{
  printf("Hello, world!");
  return 0;
}

На Python для этого мне бы понадобилась одна строка! Зато вместо миллисекунд Python (конкретно для этого случая) у меня на вывод ушло, ну... Гораздо меньше времени. Вычитая компиляцию в бинарник.

После часа изучения курса по Си я осмелел и даже написал что-то в духе:

#include <stdio.h>

int main()
{
	int x = 7;
	int y = 2;
	printf("%f", (float)x / (float)y); // вывод: 3.500000
	return 0;
}

Код объявляет две целочисленных переменных: x (7) и y (2). А затем делит их между собой (до этого переведя их в формат типа 7.0 и 2.0) и получает 3.500000 на выводе.

Небольшое пояснение: в Си при делении целых чисел 7 / 2 получится 3. Деление двух целых чисел работает здесь как оператор // в Python. Если вы хотите получить конкретный результат, то переводите числа в float или double.

Возмужавши от своих подвигов на курсе, я решил слегка выйти за его рамки и написать что-то свое. Например, те же "камень-ножницы-бумагу". Особенно после того, как я узнал, что в stdlib.h есть функ��ия rand(), которая, впрочем, работает чуть иначе, чем в Python. Но об этом осознании чуть попозже.

Веселая нарезка (страданий) моего мозга

Прототип на Python выглядел бы примерно так:

import random
plays = ["rock", "paper", "scissors"]

# Обработка ввода игрока
p_choice = input()

while p_choice != "q": # выход при вводе q
  player = player.lower() # перевод всего ввода в нижний регистр
  c_choice = random.choice(plays) # выбор компьютера

  # Логика сравнения
  if p_choice == c_choice:
    print("Draw!")
  elif p_choice == "rock":
    if c_choice == "scissors":
      print("You won!")
    else:
      print("Computer won!")
  elif p_choice == "scissors":
    if c_choice == "paper":
      print("You won!")
    else:
      print("Computer won!")
  elif p_choice == "paper":
    if c_choice == "rock":
      print("You won!")
    else:
      print("Computer won!")
  
  print() # пропустить строку
  p_choice = input()

На Си это оказалось гораздо сложнее. Мне пришлось столкнуться с:

  • указателями строк;

  • сегфолтами;

  • определением функций (и я не про int main);

  • массивами;

  • условиями;

  • и прочими буками Си

Начнем с того, что при попытке объявить массив я написал:

char arr[3] = {"rock", "paper", "scissors"};

...НЕТ. То есмь массив из 3 символов, а я пытался запихнуть туда строки. Надо делать либо:

char *arr[3] = {"rock", "paper", "scissors"}; 
// массив указателей

Либо:

char arr[3][10] = {"rock", "paper", "scissors"}; 
// двумерный массив из 3 строк по 10 символов
// этот способ мне не понравился, так что я взял первый

Потом я принялся реализовывать рандом. Избалованный Python, я попробовал функцию rand() из stdlib.h:

#include <stdio.h>
#include <stdlib.h>

int main()
{
	char *plays[3] = {"rock", "paper", "scissors"};
	int count = sizeof(plays) / sizeof(plays[0]);
	int index = rand() % count;
	printf("%s", plays[index]);
	return 0;
}

Вот только чего-то не хватает. Почему-то мне всегда выпала только "бумага": я не задал семя рандома (srand). Для исправления сего недоразумения мне пришлось сделать так:

#include <stdio.h>
#include <stdlib.h>
#include <time.h>  // нужно для time()

int main() {

    srand(time(NULL));  // устанавливаем яблоко рандома

    char *plays[3] = {"rock", "paper", "scissors"};
    int count = sizeof(plays) / sizeof(plays[0]);
    int index = rand() % count;
    printf("%s\n", plays[index]);
    return 0;
}

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

#include <unistd.h>

unsigned int seed = time(NULL) ^ getpid();
srand(seed); // устанавливаем яблоко рандома (но лучше!)

Выглядит это так:

  • мы получаем текущее время на момент запуска;

  • а также PID операции (все PIDы разные в рамках одной операции ОС);

  • побитовое XOR (^) комбинирует оба источника энтропии, усиливая перемешку;

Теперь наш генератор работает горвздо лучше!

Позже, когда я добрался до написания условий, я решил посмотреть, как сравнивать строки в Си, и не зря. Тут не прокатит прямое сравнение, как в Python: в Си для этого существует функция strcmp(str1, str2), возвращающая число, равное:

  • 0, если они равны;

  • -1, если первый ASCII-символ первой строки меньше второго;

  • 1, если наоборот

Прозвучало страшно, но я быстро понял, как это использовать. Я создал условие вида if (strcmp(p_choice, "scissors") == 0), которое определит, ввел ли игрок "scissors". Сам же алгоритм определения выглядит так:

#include <stdlib.h>
#include <string.h>

int main()
{
		if (strcmp(c_choice, p_choice) == 0) 
		{
			printf("\nDraw!\n");
		}
	
		if (strcmp(c_choice, "rock") == 0 && strcmp(p_choice, "paper") == 0 || strcmp(c_choice, "scissors") == 0 && strcmp(p_choice, "rock") == 0 || strcmp(c_choice, "paper") == 0 && strcmp(p_choice, "scissors") == 0) // условия победы игрока
		{
			printf("\nPlayer won!\n");
		}

		if (strcmp(c_choice, "paper") == 0 && strcmp(p_choice, "rock") == 0 || strcmp(c_choice, "scissors") == 0 && strcmp(p_choice, "paper") == 0 || strcmp(c_choice, "rock") == 0 && strcmp(p_choice, "scissors") == 0) // условия победы компьютера
		{
			printf("\nComputer won!\n");
		}
}

Не рекомендую вникать в код: из-за своих ограниченных технических способностей в синтаксисе Си я решил не рисковать, за что поплатился качеством человеческого восприятия кода. Если бы эту статью писал ИИ, он бы определенно стал размышлять на тему лишения человечности во имя алгоритмов машин, но я не буду этим заниматься.

После этого я столкнулся с еще одной напастью: почему-то if игнорировал мой ввод! Я не знал, как это произошло (я все же вводил "paper", я искренне не понимаю, как это случилось), но на всякий случай я решил определить функцию, переводящую ввод в нижний регистр.

Видите ли, у Си нет прямого аналога питоновского .lower(). Есть только tolower() из ctype.h, но он охватывает только один символ, а потому мне пришлось объявить первую Си-функцию, которая НЕ является main():

#include <ctype.h>

// перевод в нижний регистр
void lower(char *str) {
	for (int i = 0; str[i]; i++) {
		str[i] = tolower(str[i]);
	}

Я буду честен: я больше скопировал эту функцию, чем написал ее. Все же я вник, и понял, как она работает:

  • void — наша функция ничего не возвращает (int перед main() означает, что main() вернет число);

  • char *str — указатель на строку;

  • for здесь, в принципе, остался в том же амплуа, что и в Python, разве что замаксировался иначе;

  • int i = 0 — в тандеме с for и i++ образует выражение вида питоновского for i in range;

  • str[i] — как в Python, берет индекс буквы в строке;

  • str[i] = tolower(str[i]) — меняет символ на этот же символ нижнего регистра (в Python строки неизменяемые, но в Си вполне)

И в логике ввода:

int main()
{
    printf("Enter your choice:\t");
		scanf("%s", p_choice);
		lower(p_choice); // теперь наш ввод в нижнем регистре
		printf("Computer picked %s", c_choice);
		printf("\nYour input: %s", p_choice);
}

Неизвестно почему, но опосля определения функции все заработало.

Кстати, о вводе:

char *p_choice = "...";
printf("Введите выбор: ");
scanf("%s", &p_choice);

Никогда так не делайте. Тут дорога только в сегфолт (Segmentation fault).

  • p_choice — это указатель на строковый литерал "...". Они хранятся в памяти для чтения, их нельзя менять;

  • &p_choice передавал адрес указателя, а не адрес буфера для строки;

  • Введенная строка летит в память чтения — поздравляем, вы выиграли "Segmentation fault"!

Лично я решил проблему, изменив секцию на оную типа:

char p_choice[100];  // буфер для ввода в 100 символов
printf("Введите выбор:\t");
scanf("%s", p_choice);  // без &, потому что массив уже адрес

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

while (strcmp("q", p_choice) != 0)
{
  // ...логика игры...
  // ...тут же и логика ввода пользователя в конце...
}

if (strcmp("q", p_choice) == 0) //окончание игры
{
	printf("\nQuitting...\n");
}

После тестирования я выдохнул с облегчением. Наконец, я закончил с логикой этой игры, полностью переведя ее с Python на Си. И если в Python мне бы понадобилось 25 строк, то на Си мне понадобилось 45 строк. Большинство из них — фигурные скобки на отдельной строке, да и код кое-где можно было бы оптимизировать, но эта задача пока не для меня.

Моя реализация Си-кода выглядит так:

// #include <stdio.h> в принципе, можно исключить во имя stdlib.h
#include <stdlib.h>
#include <time.h>
#include <ctype.h>
#include <unistd.h>
#include <string.h>

// перевод в нижний регистр
void lower(char *str) {
	for (int i = 0; str[i]; i++) {
		str[i] = tolower(str[i]);
	}
}

int main()
{
	unsigned int seed = time(NULL) ^ getpid();
	srand(seed); // устанавливаем яблоко рандома
	
	char *plays[3] = {"rock", "scissors", "paper"};
	int count = sizeof(plays) / sizeof(plays[0]);
	int index = rand() % count;
	char *c_choice = plays[index]; // рандомный выбор
	
	char p_choice[100];
	while (strcmp("q", p_choice) != 0)
	{
		printf("Enter your choice:\t");
		scanf("%s", p_choice);
		lower(p_choice);
		printf("Computer picked %s", c_choice);
		printf("\nYour input: %s", p_choice);
	
		if (strcmp(c_choice, p_choice) == 0) 
		{
			printf("\nDraw!\n");
		}
	
		if (strcmp(c_choice, "rock") == 0 && strcmp(p_choice, "paper") == 0 || strcmp(c_choice, "scissors") == 0 && strcmp(p_choice, "rock") == 0 || strcmp(c_choice, "paper") == 0 && strcmp(p_choice, "scissors") == 0) // условия победы игрока
		{
			printf("\nPlayer won!\n");
		}

		if (strcmp(c_choice, "paper") == 0 && strcmp(p_choice, "rock") == 0 || strcmp(c_choice, "scissors") == 0 && strcmp(p_choice, "paper") == 0 || strcmp(c_choice, "rock") == 0 && strcmp(p_choice, "scissors") == 0) // условия победы компьютера
		{
			printf("\nComputer won!\n");
		}
	}
	// окончание игры
	if (strcmp("q", p_choice) == 0)
	{
		printf("\nQuitting...\n");
	}
	return 0;
}

Говорите, что хотите, но я горжусь этим кодом. Написал я его на второй день изучения Си (возможно, мне не стоило заскакивать вперед курса и искать решения багов окаяных), но в конечном итоге у меня получилось.

Взято с https://m.vk.com/itumor?from=groups
Взято с https://m.vk.com/itumor?from=groups

В конце можно замолвить словечко о том, что учить Си пусть и будет нелегко, но зато код будет быстрым и эффективным. Мне понравилось то, что в этом языке все надо настраивать самому, а это уже по-настоящему максимальный контроль. printf() и scanf(), как ни странно, тоже показались мне гораздо дружественнее (никогда такого не было, чтобы я ошибся в указателях с ними. Хоть убейте — не было!). Пусть функции с массивами позже и заставили меня взвыть, но в целом Си мне понравился.

Ожидаю фидбека в комментариях!