Урок 2: Организация проекта

Вступление

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

Сложный программный проект может состоять не только из файлов исходного кода, но так же из объектных файлов, генерируемых файлов исходного кода и исполняемых файлов, набора тестов, и прочих файлов. Чтобы не запутаться во всём этом разнообразии, требуется организовать прозрачную и понятную системы сборки, тестирования, документирования, анализа кода. В данном уроке мы создадим инфраструктуру для простой программы, выводящей сообщение "Здравствуй, мир!". Мы будем использовать следующие инструменты:

Выполнение

Организация каталогов

Первым делом мы создаём каталог нашего проекта и переходим в него:

$ mkdir x

$ cd x

Внутри каталога мы организуем хранение файлов удобным для нас образом. Во-первых необходим каталог с исходным кодом, который обычно называется src:

$ mkdir src

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

/**
 * @file main.c
 * @brief Здесь входим в программу
 * @author Маркин Алексей (alexanius.ru)
 */

/**
 * @mainpage Введение
 *
 * Это первое упражнение. В нём мы создаём простой файл на языке Си чтобы
 * настроить основную инфраструктуру проекта. В частности, мы настраиваем
 * системы сборки, документации, покрытия и тестирования.
 */

#include <stdio.h>

/**
 * @brief Вход в программу
 *
 * @returns 0 если всё прошло успешно
 */
int main(void)
{
    printf("Здравствуй, мир!\n");
    return 0;
}

Может показаться странным что комментариев в программе больше чем полезного кода, но это скоро изменится. А не изменится то что мы будем для КАЖДОЙ функции писать комментарии с описанием функции, её параметров и возвращаемого значения.

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

Разумеется, для исполняемого файла у нас будет отдельный каталог, обычно он называется bin, внутри него будут ещё два каталога: debug и release. Первый предназначен для сборок с отладочной информацией, а второй для финальных сборок, предоставляемых пользователю.

$ mkdir -p bin/debug bin/release

$ gcc src/main.c -o bin/release/x

$ ./bin/release/x
Здравствуй, мир!

Как видно, сама по себе программа работает, однако это только самое начало создания инфраструктуры проекта.

Система контроля версий

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

В нашем проекте мы будем использовать систему контроля версий Mercurial как довольно простую и удовлетворяющую всем нашим потребностям. Для начала создадим репозиторий в корневом каталоге проекта:

$ hg init .

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

$ hg add src/main.c

Ну и теперь можно зафиксировать точку развития проекта, выполнив влив:

$ hg commit

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

$ hg log
changeset: 0:45dfe15430ec
tag: tip
user: alex
date: Wed Sep 30 03:32:10 2020 +0300
summary: Первый файл проекта

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

Опции сборки

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

Для отладочной сборки мы будем использовать следующий набор опций: -g -fsanitize=address -Wall -Werror -std=c18. Рассмотрим что означает каждая из этих опций:

Проведём простой тест чтобы убедиться в работоспособности:

$ gcc -g -fsanitize=address -Wall -Werror -std=c18 src/main.c -o bin/debug/x_d

$ ./bin/debug/x_d
Здравствуй, мир!

Во многом именно сборка с перечисленными опциями позволит нам не «отстрелить себе ногу» во время разработки компилятора на Си, и я бы рекомендовал вообще почаще использовать их в своих собственных разработках.

Система сборки

Разумеется, ни для какого сколько-нибудь сложного (а то что мы делаем уже довольно сложно) проекта невозможно обойтись без системы сборки. Мы будем использовать стандартный инструмент под названием make.

Теперь займёмся непосредственно созданием сценария сборки. Он расположен в корне проекта в файле Makefile. Сценарий содержит в себе объявление переменных в стиле bash и набор действий в зависимости от заказанной сборки. Чтобы было понятней приведём практический пример запуска сборки:

$ make
mkdir -p bin/release obj/release
gcc -Wall -Werror -std=c18 src/main.c -c -o obj/release/main.o
gcc obj/release/*.o -o bin/release/x

Команда make по умолчанию смотрит в файл Makefile и запускает цель сборки под названием all. По умолчанию мы запускаем финальный вариант сборки с соответствующими опциями. Для отладочной сборки необходимо создать цель debug и запускать её:

$ make debug
mkdir -p bin/debug obj/debug
gcc -Wall -Werror -std=c18 -g -fsanitize=address src/main.c -c -o obj/debug/main.o
gcc -g -fsanitize=address obj/debug/*.o -o bin/debug/x_d

Стоит обратить внимание что сборка становится несколько сложнее чем до использования make: появляется каталог obj и компилятор вызывается два раза. Это более общий способ сборки, рассчитанный на несколько модулей компиляции. Сначала мы создаём объектные файлы .o в отдельном каталоге, потом для этих объектных файлов вызывается компоновщик (второй вызов gcc), который создаёт исполняемый файл.

Про создание сценариев сборки можно писать довольно много, и лучше всего почитать специализированные материалы. Я приведу только самый простой вариант сценария (в эталонном коде в конце урока всё будет совершенно иначе), который позволит нам собрать две цели и на примере покажет возможное содержимое такого файла:

all:
	mkdir -p bin/release obj/release
	gcc -Wall -Werror -std=c18 src/main.c -c -o obj/release/main.o
	gcc obj/release/*.o -o bin/release/x

debug:
	mkdir -p bin/debug obj/debug
	gcc -g -fsanitize=address -Wall -Werror -std=c18 src/main.c -c -o obj/debug/main.o
	gcc -g -fsanitize=address obj/debug/*.o -o bin/debug/x_d

clean:
	rm -r bin obj

Можно заметить что здесь появилась и третья цель clean, которая удаляет все созданные в процессе сборки файлы. Наличие такой цели будет хорошим тоном для файла сценария сборки. Осталось только добавить этот файл в систему контроля версий и зафиксировать изменения.

Система тестирования

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

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

Мы же ограничимся простыми наборами тестов и сценарием выполнения тестирования на языке bash. Дальнейшие примеры станут содержимым файла test.sh, который мы также создадим к корневом каталоге проекта. Т.к. в текущем уроке у нас есть только программа, выводящая одно сообщение, то при запуске тестов нам нужно сначала убедиться что тестируемый файл вообще существует:

# Первый аргумент сценария - имя тестируемого файла
BIN=$1

# Проверяем что тестируемый файл существует
if [[ $BIN == "" ]] ;
then
    echo "Ошибка: не задан тестируемый исполняемый файл."
    echo "        Тестируемый файл должен быть задан первым аргументом"
    exit 1
fi

потом что он исполняемый:

# Проверяем что тестируемый файл исполняемый
if [[ ! -x $BIN ]] ;
then
    echo "Ошибка: тестируемый файл $BIN не является исполняемым"
    exit 2
fi

Запуск этого сценария будет происходить следующим способом:

$ make debug
mkdir -p bin/debug obj/debug
gcc -g -fsanitize=address -Wall -Werror -std=c18 src/main.c -c -o obj/debug/main.o
gcc -g -fsanitize=address obj/debug/*.o -o bin/debug/x_d

$ sh bin/debug/x_d

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

$ mkdir tests

# Каталог с правильными входными данными
$ mkdir -p tests/in/correct

# Содержимое файла нам не важно, сценарий тестирования потребует его наличия
$ touch tests/in/correct/1.1.x

# Каталог с образцами выходных данных
$ mkdir -p tests/out/correct

# Каталог с образцами выходных данных
$ cat > tests/out/correct/1.1.x.sample
Здравствуй, мир!

Можно заметить что несмотря на отсутствие входных данных мы создали файл-заглушку для них. Это связано с принципом работы сценария тестирования. Чтобы сделать его максимально простым и не писать файл-конфигурацию к каждому тесту, сценарий будет просто просматривать каталог входных данных tests/in/correct, подавать каждый файл на вход тестируемому компилятору и сравнивать его выдачу с соответствующим файлом из каталога tests/out/correct. При этом нужно предусмотреть временный каталог для хранения и сравнения файлов с образцом, им у нас будет tmp/in/correct. Далее допишем в файл сценария тестирования test.sh:

# Настраиваем переменные тестирования
INPUT_CORRECT_DIR="tests/in/correct"
SAMPLE_CORRECT_DIR="tests/out/correct"
OUTPUT_CORRECT_DIR="tmp/in/correct"

# Тестируем корректные примеры
mkdir -p $OUTPUT_CORRECT_DIR
for i in `ls "$INPUT_CORRECT_DIR"` ;
do
    echo -n "Тест $i: "

    # Запускаем тестируемый компилятор и проверяем что он вернул 0
    $BIN "$INPUT_CORRECT_DIR/$i" >& $OUTPUT_CORRECT_DIR/"$i.sample"
    if [ `echo $?` != 0 ]
    then
        echo "ошибка в запуске: $BIN $INPUT_CORRECT_DIR/$i"
        RES=3
        continue
    fi

    # Сравниваем выдачу тестируемого компилятора с образцом
    diff -q "$OUTPUT_CORRECT_DIR/$i.sample" "$SAMPLE_CORRECT_DIR/$i.sample"
    if [ `echo $?` != 0 ]
    then
        RES=4
        echo "ошибка в запуске: diff -q "$OUTPUT_CORRECT_DIR/$i.sample" "$SAMPLE_CORRECT_DIR/$i.sample""
    else
        echo "пройден!"
    fi
done

exit $RES

И ещё раз проверим тест:

$ sh test.sh bin/debug/x_d
Тест 1.1.x: пройден!

Осталось только сделать тестирование удобным. Для этого в файле сценария сборки следует создать цель test, которая соберёт отладочную версию компилятора и запустит тесты:

test : debug
	sh test.sh bin/debug/x_d

В этом правиле добавился новый элемент - зависимость. Мы видим что цель test зависит от цели debug, поэтому сначала будет выполнена цель debug, а потом начнёт своё исполнение цель test. Смотрим:

$ make test
mkdir -p bin/debug obj/debug
gcc -g -fsanitize=address -Wall -Werror -std=c18 src/main.c -c -o obj/debug/main.o
gcc -g -fsanitize=address obj/debug/*.o -o bin/debug/x_d
sh test.sh bin/debug/x_d
Тест 1.1.x: пройден!

Сбор покрытия

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

Мы будем собирать покрытие классическим инструментом под названием gcov, а визуализировать его при помощи инструмента lcov. Покрытие мы будем собирать на нашем тестовом наборе, поэтому в сценарии сборки появится ещё одна цель, которая собирает компилятор с нужными опциями, потом запускает тестирование, а потом создаёт отчёт с покрытием:

$ make cov
rm -rf obj/debug/*gcno obj/debug/*gcda cov
mkdir -p bin/debug obj/debug
gcc -Wall -Werror -std=c18 -fprofile-arcs -ftest-coverage -g -fsanitize=address src/main.c -c -o obj/debug/main.o
gcc -lgcov --coverage -g -fsanitize=address obj/debug/*.o -o bin/debug/x_d
sh test.sh bin/debug/x_d
Тест 1.1.x: пройден!
mkdir -p cov
lcov -t x -o cov/x.info -c -d obj/debug
...

Разберём подробнее что произошло. Первая команда сценария cov выполнила очистку старых файлов покрытия чтобы избежать наложения данных. Далее идёт создание каталогов и вызов компилятора с необходимыми опциями. У нас добавилось две новые опции: -fprofile-arcs и -ftest-coverage. Они обе нужны для сбора покрытия. Второй вызов gcc - это вызов компоновщика, ему тоже нужно подавать дополнительные опции: -lgcov и --coverage. Ну и последней командой в цепочке вызовов стал вызов lcov - генератора отчётов о покрытии. Результат его работы можно будет найти в каталоге cov.

Автоматическое документирование

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

$ doxygen -g

$ ls
Doxyfile Makefile src test.sh tests

Эта команда создала нам файл конфигурации Doxyfile. В нём менять нужно не очень много, ниже приведены только переменные, претерпевшие изменения:

PROJECT_NAME = "Язык X"

# Документация будет создаваться в каталоге doc
OUTPUT_DIRECTORY = doc

OUTPUT_LANGUAGE = Russian

# Каталог, в котором ищем разметку
INPUT = src

# Включаем поиск по вложенным каталогам
RECURSIVE = YES

# Создание документации в latex нам не нужно, достаточно просто html
GENERATE_LATEX = NO

Теперь достаточно просто запустить создание документации, и всё будет автоматически выполнено:

$ doxygen
...
$ ls doc/html/index.html
doc/html/index.html

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

Итог

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

$ ls
Doxyfile Makefile src test.sh tests

Мы можем запускать сборку проекта:

$ make
mkdir -p bin/release obj/release
gcc -Wall -Werror -std=c18 src/main.c -c -o obj/release/main.o
gcc obj/release/*.o -o bin/release/x

запускать тесты:

$ make test
mkdir -p bin/debug obj/debug
gcc -Wall -Werror -std=c18 -g -fsanitize=address src/main.c -c -o obj/debug/main.o
gcc -g -fsanitize=address obj/debug/*.o -o bin/debug/x_d
sh test.sh bin/debug/x_d
Тест 1.1.x: пройден!

запускать сбор покрытия:

$ make cov
...
Writing directory view page.
Overall coverage rate:
lines......: 100.0% (3 of 3 lines)
functions..: 100.0% (1 of 1 function)

запускать создание документации:

$ make doc
...
finished...

После всех действий мы получим следующую структуру каталогов:

$ ls
Doxyfile Makefile bin cov doc obj src test.sh tests tmp

Посмотреть эталонный пример документации можно по этой ссылке, а эталонный пример покрытия - по этой.

После всех действий имеет смысл почистить всё лишнее:

$ make clean
rm -rf obj/*gcno obj/*gcda cov
rm -fr bin obj doc tmp

Эталонную реализацию можно посмотреть здесь. На следующем уроке мы перейдём к созданию лексического анализатора нашей лицевой части.