Когда я только начинала изучать язык C, меня довольно сильно пугала его "топорность" по сравнению с другими языками. Все довольно строгое, управляемое вручную, но именно этим он и привлек меня. Потому что ощущение, будто ты напрямую разговариваешь с системой.
В какой-то момент в моем поле появилась задачка: написать две утилиты линуксоидного существа cat и grep. Несмотря на то, что они кажутся довольно простыми, они оказались отличной возможностью погрузиться в работу с файлами, и понять, даже поверхностно, как работает язык C и с чем его едят.
В статье постараюсь рассказать и показать ход своих мыслей и почему теперь я смотрю на консольные команды совсем иначе. В этой статье я подробно остановлюсь только на реализации утилиты cat, а про то, как написать grep, вы можете почитать в моей другой статье по ссылке, которую скоро добавлю.
Что за зверь этот Cat
Cat показалась мне более простой в написании, чем grep, да и частично grep строится на базе cat – тоже читает данные из файла, тоже работает со строками, но с небольшим нюансом в виде фильтрации.
Что нам важно понимать на старте:
cat – это не просто "прочитай файл и выведи его". Он должен уметь обрабатывать несколько файлов подряд, нумеровать строки (и здесь же отделять пустые от непустых), схлопывать пустые строки, отображать табуляцию и показывать символ $ в конце строки. Всё это реализуется с помощью switch case и прописывания логики к кажому кейсу;
В C предстоит работа с более высокоуровневым API (fopen, fclose, fgetc, fputs) – выбор подхода очень важен.
Все надо делать вручную: и буферизацию, и проверку ошибок;
Важно читать именно посимвольно, а не построчно.
Общая структура программы
Чтобы не потеряться в десятках строк, разобьем нашу кошачью утилиту на три части:
Получение и разбор флагов
Перебор всех файлов
Построчный (точнее посимвольный) вывод с обработкой опций
int main(int argc, char *argv[]) {
Flags flags = {0}; // обнуляем структуру флагов
parsing_flags(argc, argv, &flags); //разбираем опции, объявляем наш парсер
for (int i = optind; i < argc; ++i) { //обрабатываем каждый файл
print_file(argv[i], flags);
}
return 0;
}
Здесь у нас argc общее число аргументов, которое мы передаем в нашу программу, включая имя самой утилиты. Важно понимать, что argv это массив всех элементов строчки, где лежат все переданные аргументы:
argv[0] – всегда имя программы ./my_cat
argv[1] – первый аргумент программы (любой флаг утилиты, например -n)
argv[2] – второй аргумент (к примеру file.txt, также может лежать еще один флаг)
ну и так далее.
Flags flags – наша структура, в которой объявлены все флаги (b, e, E, n, s, t, T, v). Её нужно обнулить, чтобы по умолчанию все опции были выключены и не возникло казусов при запуске программы.
В цикле for мы обрабатываем файл с учетом переданных в терминал флагов, где optind это индекс того аргумента, который не является опцией (т.е. не -n не -e и т.д.). Optind вообще это глобальная переменная, которая увеличивается в зависимости от переданных аргументов. Он не глупенький и понимает, когда ему нужно остановиться, поэтому при виде файла он останавливается. Это нужно для того, чтобы отделить "что передано программе как опции" от "что передано как данные/файлы и т.д."
Разбор флагов
В cat нужно реализовать флаги, которые выполняют различные функции, о которых я мельком упоминала в самом начале
-n — нумерация всех строк: подсчет строк, вывод номера перед каждой строкой;
-b — нумерация только НЕПУСТЫХ строк: аналогично -n, но пропускаем пустые;
-s — удаление повторяющихся пустых строк: хранить флаг о предыдущей пустой строке;
-E — показывать $ в конце строки: проверка символа новой строки, добавление $;
-T — отображение табуляции как ^I
: обработка каждого символа в строке;
Как можно заметить, у нас есть как большие, так и маленькие флаги. Более того, большие флаги (-E, -T
) повторяют маленькие флаги (-e, -t
) по функционалу, но есть нюанс. Большие флаги работают с помощью вспомогательного флага -v
, в то время как маленькие уже включают в себя их.
while ((opt = getopt_long(argc, argv, "bEnstvT", longopt, NULL)) != -1) {
switch (opt) {
case 'b':
flags->b = 1; // нумеруем непустые строки
break;
case 'e':
flags->e = 1; // добавляем $ в конце строки
flags->v = 1; // вспомогательный флаг
break;
// и т.д.
}
}
Вообще getopt_long
, который вы можете заметить в цикле из библиотеки getopt. Да, можно обойтись и без нее написав свой парсер, но как по мне, с ним выглядит все проще и лаконичнее. Суть getopt-long'а в том, что она расширяет функциональность стандартной функции getopt, поддерживая как большие флаги (-T, -E
), так и длинные опции (--help
). Здесь как раз я беру его для поддержки больших флагов.
Чтение файла и базовый вывод
Чтобы вообще на данном этапе разобраться, читает ли ваш cat файлы, можно написать довольно простую функцию:
FILE *f = fopen(name, "r"); // открываем файл
int c;
while ((c = fgetc(f)) != EOF) {
putchar(c); // выводим посимвольно наполнение файла
}
fclose(f);
Благодаря вызову fopen
и режиму для чтения r
(read) открываем наш файл. А дальше заводим цикл, где int c
– символ, который нам нужно захватить. Обязательно нужно указать, что идем строго до EOF (end of file), поскольку C, довольно "грязный" язык, который может хранить в себе после последнего символа различный мусор. Ну и когда дошли до конца – также посимвольно выводим весь наш текст в терминал и закрываем файл (f)
с помощью fclose
.
По факту из всех 6 строк данного кода нам понадобится всего две: открытие файла и условие цикла для печати файла. Их мы будем добавлять в реализацию логики флагов, а уже после крутить и вертеть файл так, как нам нужно.
Логика флагов
Последний этап реализации – логика флагов. Чтобы они корректно отрабатывали, я заводила переменные типа int, которые выполняли различные функции в зависимости от задачи флага. Разберем каждую опцию отдельно.
Нумерация строк (-n): чтобы нумеровать строки нам нужен счетчик и флаг, который будет обозначать начало новой строки. При переходе на новую строку мы просто увеличиваем счетчик.
Нумерация НЕ пустых строк (-b): самое главное отличие от -n
так это то, что нужно номеровать строки, в которых есть хотя бы один символ, который отличается от \n
(поэтому мы и идем посимвольно с помощью fgetc
). Также берем флаг, обозначающий начало новой строки и прописываем грамотное выполнение условия
Схлопывание пустых строк (-s): здесь нам нужно не выводить более одной идущей пустой строки. Я завела переменную empty, которая считает идущие подряд \n
. При каждой встрече с \n
увеличиваем empty
if (c == '\n' && prev_ch == '\n') {
empty++;
} else {
empty = 0;
}
// если уже больше одной пустой и включён флаг -s — пропускаем всю обработку символа
if (flags.s && empty > 1) {
prev_ch = c;
Отображение конца строки (-E, -e): если в условии стоит флаг -E
, то перед каждым \n
принтим маркер $. С -e такая же история, только добавляется -v
, который показывает невидимые символы (об этом чуть ниже).
Отображение табуляции (-T, -t): Нам нужно заменить символ \t
на последовательность ^I
. Здесь советую проверить в ваших тестовых файлах, точно ли у вас стоит табуляция, иногда вместо нее могут стоять пробелы. Пробелы не подсвечиваются, только TAB.
if (flags.t && c == '\t') {
// флаг t уже включает v, но тут заменяем сам таб
printf("^");
c = 'I';
}
Показ управляющих (невидимых) символов (-v): на самом деле cat по умолчанию выдает всё как есть в файле. Но в реальности файлы могут содержать определенные спец символы, так в .txt
это каретки, которые могут выглядеть вот так \n \r
и т.д. Если их просто пробросить на экран, то вы скорее всего не поймете, откуда взялся лишний пробел или отступ. Поэтому нашей целью является отображение управляющих символов и символов с кодами, например:
if (c == 127) {
printf("^?");
Ну а как все закончили просто закрываем файл с помощью fclose
и всё! Cat написан!
Реализуя утилиту cat, я поняла, что даже за простыми командами скрывается целый мир низкоуровневой работы с файлами, символами и памятью. То, что раньше казалось элементарным «прочитал-вывел», на самом деле требует внимательности к деталям, понимания принципов языка и проработки логики обработки данных. Теперь я вижу cat не просто как утилиту, а как отличную отправную точку для знакомства с языком C и Linux-утилитами!