Разделение на модули

TLTR

Человеческий мозг плохо справляется с обработкой больших объемов неструктурированной информации. А программное обеспечение является примером сложной системы, причем сложность программных продуктов постоянно увеличивается. Википедия приводит данные о числе строк кода в Windows и Linux. Так, например, ядро Linux в 1991 году содержало 10 тысяч строк кода, в 1999 — миллион, а в 2017 — более 18 миллионов строк кода, то есть в 1000 раз больше, чем в первой версии. Возникает естественный вопрос: как люди справляются с написанием таких больших программ?

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

Конечно, подсистемы и системы — это особенность крупных проектов. Мы ограничимся примером модуля, но принцип остается общим: выделить изолированную и более простую задачу.

Модуль представляет собой решение некоторой логически замкнутой задачи. Ключевыми понятиями являются интерфейс и реализация.

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

Интерфейс позволяет скрыть сложные решения и использовать модуль как "черный ящик". Так, в известной системе символьных вычислений Wolfram Mathematica есть функция разложения числа на множители. С точки зрения пользователя функция FactorInteger выглядит предельно ясно: на вход подается целое число, на выходе получаем список его делителей. При этом разработчики системы хвалились (ссылка не источник не указана!), что эта функция работает очень эффективно, а реализация метода составляет более 500 страниц кода на Си (конечно, в этой браваде чувствуется рекламный подтекст: "Не пытайтесь повторить такое в домашних условиях! Покупайте наше").

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

Модули в языке Си

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

Пусть нам нужно вычислять определенный интеграл от произвольной функции действительного аргумента (хорошо-хорошо, значения типа double), находить минимум функции и решать другие стандартные задачи численного анализа. Наша цель — написать модуль методов численного анализа. Назовем его numalgs (numerical algorithms). Начнем с написания интерфейса. Если мы не сможем понять что делать, то нечего браться за реализацию.

Интерфейс (заголовочный файл)

У модуля обычно есть один заголовочный файл, имя которого совпадает, или как-то связано, с название модуля. В нашем примере заголовочный файл будет назваться numalg.h. В этом файле мы объявляем тип данных для функций действительного аргумента, коды ошибок, которые могут возникать при выполнении наших численных методов, и прототипы "основных" функций.

Заголовочный файл нашего модуля будет иметь примерно такое содержание.


    /** Множество функций из R в R. */
    typedef double (*RRFun)(double);

    /** Код ошибок функций. Префикс NA означает Numerical Algorithms. */
    typedef enum {
        NA_OK       = 0,   /* Корректное выполнение */
        NA_CONVERGE = 1,   /* Не удалось вычислить значение с заданной точностью */
        NA_PARAM    = 2    /* Некорректное значение входных параметров вызова */
    } NAError;
    
    /**
    ** Вычисление приближения определенного интеграла функции f на отрезке [a, b] с точностью epsilon.
    **
    ** Параметры:
    **   f: подынтегральная функция.
    **   a, b: нижний и верхний предел интегрирования.
    **   epsilon: требуемая точность.
    **   err: адрес для сохранения кода возврата.
    **
    **  Если передается ненулевое значение err, то по окончании функции по этому адресу записывается
    **  код ошибки. Если err имеет значение NULL, то функция не записывает код возврата.
    **    
    ** Возвращаемое значение:
    **   В случае успеха функция возвращает значение интеграла, а по адресу err записывается код NA_OK.
    **
    **   Если значение интеграла не удается получить с заданной точностью, то возвращается
    **   его приближение. В этом случае по адресу err записывается код NA_CONVERGE.
    **
    **   В случае некорректных входных значений, a > b, нулевой адрес f, функция возвращает 0
    **   и записывает по адресу err код NA_PARAM.
    */
    double integrate(RRFun f, double a, double b, double epsilon, NAError *err);

    /**
    ** Поиск аргумента минимума функции f на отрезке [a, b].
    **
    ** ... подробное описание ...
    */
    double minimize(RRFun f, double a, double b, double epsilon, NAError *err);
    

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

Что включать в заголовочный файл интерфейса модуля? В файле должны быть приведены прототипы всех общедоступных функций, и определены все типы, необходимые для успешной трансляции этого файла. Например, если одна из интерфейсных функций модуля получает на вход открытый файл, то есть FILE *, то необходимо включить заголовочный файл stdio.h.

Чего НЕ ДОЛЖНО быть в заголовочном файле? Там не должно быть ничего лишнего. Не следует в этом файле объявлять функции, которые нужны только для реализации интерфейсной функции, то есть не предполагается, что их будет использовать кто-то еще. Чем меньше пользователь будут знать о деталях реализации, тем лучше. Не надо включать лишние заголовочные файлы "на всякий случай", даже если этот заголовочный файл потребуется в реализации. Включите этот заголовочный файл в том файле реализации, где он понадобится. Интерфейс должен быть в некотором смысле минимальным.

Реализация (.c-файлы)

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

Для реализации конкретного метода, например, численного интегрирования, могут понадобиться вспомогательные функции. Если интеграл вычисляется по составной квадратурной формуле, то может понадобиться вспомогательная функция, которая вычисляет значение квадратурной формулы на заданном отрезке, а разбиение отрезка [a, b] на части с шагом h выполняется в функции integrate.

Таким образом, файл с реализацией метода численного интегрирования, integrate.c, может содержать помимо основной функции integrate несколько дополнительных, которые не включены в интерфейс модуля. Вспомогательные функции, которые используются только в том файле, где они реализованы, помечаются статическими (ключевое слово static перед типом возвращаемого значения). Если имена "обычных" функций должны быть уникальными в рамках всей программы (поскольку их можно вызывать из других исходных файлов), то область видимости статической функции ограничена одним файлом. В разных файлах могут быть статические функции с совпадающими именами.

Все перечисленное приводит нас к приблизительно такому содержанию файла integrate.c.

#include "numalgs.h"

    /** Вычисляет значение интеграла от функции f на отрезке [a, b] методом Симпсона. */
    static double simpson(RRFun f, double a, double b);
      
    /** Здесь дублируется описание из заголовочного файла, которое опущено в целях сокращения места.
    **
    ** Метод:
    **   Используется квадратурная формула второго порядка с динамическим выбором шага по правилу Рунге.
    */    
    double integrate(RRFun f, double a, double b, double epsilon, NAError *err) {
      // Реализация функции
    }

    /** Вычисляет значение интеграла от функции f на отрезке [a, b] методом Симпсона.
    **
    **  Функция f приближается на отрезке [a, b] параболой, построенной по значениям
    **  f в точках a, (a + b)/2, b.
    **
    **  Предполагается, что a < b и точка (a + b)/2 лежит строго между a и b.
    */
    static double simpson(RRFun f, double a, double b) {
      // Реализация функции
    }
    

Отметим, что все вспомогательные функции должны документироваться так же, как и функции, объявленные в интерфейсе модуля.

Использование модуля в приложении

Отлично, мы написали модуль. А как им пользоваться?

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

#include <math.h>
#include "numalgs.h"

int main() {
  double res = integrate(sin, 0, 1, 1e-7, NULL);
}

Компилируем. Здесь предполагается, что файлы модуля (заголовочный и файлы реализации) находятся в той же директории, что и main.c.

gcc -c main.c
gcc -c integrate.c
gcc -o prog main.o integrate.o
# Эквивалентная команда для "ленивых":
gcc main.c integrate.c

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

Проверка корректности входных данных

При реализации модуля следует учитывать, что он будет использоваться независимо от разработчика, то есть функции, которые объявлены в его интерфейсе, будут вызывать другие программисты. Это означает, что "если что-то может пойти не так, оно рано или поздно пойдет не так". Отсюда вытекает первое правило.

Это называется "защитой от дурака". Окружающий мир должен считаться максимально враждебным. Если какое-то условие корректности входных данных может быть проверено, то его нужно проверять. Нулевые указатели, пустые массивы, невыполнение неравенств концов отрезков и т.п. Какой бы странной не казалась ошибка, она возникнет. И гораздо раньше, чем вы думаете. (Одна известная фирма выпустила контроллер жестких дисков. Разработчики забыли отключить возможность перехода в отладочный режим, который запускался при чтении с диска четырех 32-битных чисел. В фирму поступило несколько жалоб от клиентов. Это при том, что вероятность встретить нужную последовательность в терабайтном файле составляет примерно 10-26.)

В реализации внутренних функции модуля можно предполагать корректность входных данных и не делать никаких проверок. Обоснование такое: внутренние функции может вызывать только только разработчик модуля, а от себе можем полностью доверять. В таких функциях проверку ошибок нужно включать исходя из баланса между надежностью или простотой отладки и производительностью. Проверка ошибок — это "лишние" действия. Если в интерфейсных функциях надежность является абсолютным приоритетом, то с внутренними функциями все иначе.

Второе важное правило касается документации интерфейса.

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

Резюме

В начало