Search
Write a publication
Pull to refresh

Реализация утилиты cat на языке C

Level of difficultyEasy
Reading time5 min
Views2.1K

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

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

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

Что за зверь этот Cat

Cat показалась мне более простой в написании, чем grep, да и частично grep строится на базе cat – тоже читает данные из файла, тоже работает со строками, но с небольшим нюансом в виде фильтрации.

Что нам важно понимать на старте:

  • cat – это не просто "прочитай файл и выведи его". Он должен уметь обрабатывать несколько файлов подряд, нумеровать строки (и здесь же отделять пустые от непустых), схлопывать пустые строки, отображать табуляцию и показывать символ $ в конце строки. Всё это реализуется с помощью switch case и прописывания логики к кажому кейсу;

  • В C предстоит работа с более высокоуровневым API (fopen, fclose, fgetc, fputs) – выбор подхода очень важен.

  • Все надо делать вручную: и буферизацию, и проверку ошибок;

  • Важно читать именно посимвольно, а не построчно.

Общая структура программы

Чтобы не потеряться в десятках строк, разобьем нашу кошачью утилиту на три части:

  1. Получение и разбор флагов

  2. Перебор всех файлов

  3. Построчный (точнее посимвольный) вывод с обработкой опций

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-утилитами!

Tags:
Hubs:
+3
Comments4

Articles