Когда я только начинала изучать язык 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-утилитами!
Комментарии (5)
noidol
21.08.2025 12:13Помнится, что-то подобное нужно сделать в одной из школ программирования. Как раз таки знакомит с понятием "утилиты командной строки". В ответ предыдушему комментарию, скажу, что при собственной реализации осознание сложности оригинальной утилиты становится очевидным, там реализован гораздо больший функционал...
Apoheliy
21.08.2025 12:13Конечно, неплохо бы посмотреть на полный код, возможно вы как-то странно надёргали куски кода.
Потому что по представленным фрагментам есть несколько проблем:
FILE *f = fopen(name, "r");
// открываем файл
int c;
while ((c = fgetc(f)) != EOF) {
fopen может вернуть NULL, если не смог открыть файл (файла нет, прав нет, ещё чего-нибудь нет). И тогда, как говорят интернеты, вызов fgetc это UB!
The
fgetc()
function in C expects a validFILE*
pointer as its argument, representing the stream from which to read a character. Providing aNULL
pointer as thestream
argument tofgetc()
constitutes an invalid parameter.According to the C standard, passing a
NULL
pointer to a function that expects a valid pointer results in undefined behavior. This means the program's behavior is unpredictable and may vary depending on the compiler, operating system, and execution environment.---
Судя по приведённому коду
if (c == '\n' && prev_ch == '\n') {
empty++;
...
if (flags.s && empty > 1) {
prev_ch = c;
у вас prev_ch всегда будет равен \n после первого назначения (т.к. empty > 1 только для c == '\n').
В общем, обновлять prev_ch нужно всегда без дополнительных условий. Иначе вы рискуете после небольшого рефакторинга или других правок получить изменение логики.
Надеюсь, у вас в коде написано всё правильно и корректно обновляется.
madschumacher
21.08.2025 12:13В какой-то момент в моем поле появилась задачка: написать две утилиты линуксоидного существа cat и grep. Несмотря на то, что они кажутся довольно простыми,
Серьёзно?! grep и регулярные выражения кажутся чем-то простым для реализации?!
vadimr
Если вы посмотрите на исходный текст настоящей утилиты cat, то обнаружите, что это достаточно сложная штука, использующая ряд системных трюков ради обеспечения высокой производительности. Реальный cat будет быстрее вашей посимвольнрй программы на больших файлах во много раз, и в этом его и фишка. Так что зря вы использовали такой претециозный заголовок.