C++ / Тонкости реализации кода библиотеки. Часть вторая

в 11:08, , рубрики: c++, библиотека, организация файлов, метки: , ,

C++ / Тонкости реализации кода библиотеки. Часть вторая
В предыдущей части был приведен способ, с помощью которого, можно сократить количество кода при использовании классов-помощников и классов из других пространств имен.
В данной статье речь пойдет о том, как можно реализовать размещение элементов библиотеки по файлам. Также будут затронуты вопросы подключения элементов библиотеки в пользовательском коде, и, конечно же, как «рабочие» пространства имен могут помочь в реализации библиотеки.
Подходы, применяемые при организации файлов библиотеки

Для начала определимся, что речь пойдет о библиотеках, весь код которых поставляется в виде заголовочных файлов. При создании структуры файлов в таких библиотеках придерживаются ряда правил. Не все из них можно назвать «стандартными», но применение представленных правил в существующих библиотеках не такое и редкое явление.
1) Заголовочные файлы библиотеки обычно размещают в отдельной папке.
Название папки содержит имя библиотеки или пространства имен, используемого в библиотеке. Это позволяет в пользовательском коде «документировать» использование библиотеки:
#include
#include

2) Файлы, содержащие «пользовательские» типы, и файлы, предоставляющие детали реализации, желательно размещать в разных папках.
Под «пользовательскими» типами понимаются типы, определяемые библиотекой и предоставляемые пользователю для использования в своем коде.
Применение данного правила со стороны разработчика библиотеки позволяет пользователю библиотеки легко определить файлы, которые ему необходимы для включения в своем проекте без чтения сопутствующей документации.
Для примера, некоторые библиотеки boost размещают файлы реализации во вложенной папке detail.
3) Для каждого класса библиотеки нередко создается отдельный файл с одноименным названием.
Такой подход позволяет пользователю библиотеки легко разобраться в ее структуре, а разработчику предоставляет простоту навигации по классам в своей библиотеке.
4) Файлы библиотеки должны быть самодостаточными.
В основном, это касается тех файлов, в которых определяются «пользовательские» типы и которые пользователь библиотеки подключает в своей программе или другой библиотеке посредством #include.
Подробней об этом правиле можно почитать в книге «Стандарты программирования на C++» Г.Саттера и А.Александреску (правило 23).
Описание тестовой библиотеки

Далее предположим, что нам необходимо реализовать некоторую библиотеку SomeLib. Эта библиотека должна предоставлять пользователю классы A_1, A_2 и A_3, реализующие некоторый функционал. Зеленой областью на рисунке представлена сама библиотека, красной — пространство имен, а синими — классы, предоставляемые пользователю.
Пусть библиотека SomeLib имеет зависимость от библиотеки STL и содержит «невидимые» пользователю вспомогательные классы I_1 и I_2, которые на рисунке представлены оранжевым цветом. Стрелками указаны зависимости классов от других. Например, класс A_1 зависит от классов list, vector и I_1. Под зависимостями класса в данном случае понимается использование им других классов при описании своих данных, функций-членов или реализации этих функций.
Предположим, что библиотека поставляется в виде заголовочных файлов и в качестве одного из файлов содержит config.hpp, описывающий некоторые «управляющие» структуры.
И так начнем…
Реализация тестовой библиотеки с использованием представленных правил

При реализации библиотеки воспользуемся стандартным подходом, описанным в предыдущей части. Пользовательские классы будем размещать в пространстве имен библиотеки some_lib, а служебные классы во вложенном пространстве имен impl.
Библиотека будет размещаться в папке some_lib. В этой папке будут файлы A_*.hpp, описывающие «пользовательские» типы. Файлы I_*.hpp, содержащие служебные классы, будем размещать во вложенной папке impl.
Теперь можно приступить к реализации. Пропустим описание процесса кодирования и сразу перейдем к результатам.
Файл some_lib/impl/config.hpp
#ifndef SOME_LIB__IMPL__CONFIG_HPP
#define SOME_LIB__IMPL__CONFIG_HPP

#if defined(_MSC_VER)
//...
#elif defined(__GNUC__)
//...
#else
//...
#endif

#endif

Файл some_lib/impl/I_1.hpp
#ifndef SOME_LIB__IMPL__I_1_HPP
#define SOME_LIB__IMPL__I_1_HPP

#include
#include

namespace some_lib {
namespace impl
{
class I_1
{
public:
void func( std::vector const& );
private:
// члены-данные
};
}}

#endif

Файл some_lib/impl/I_2.hpp
#ifndef SOME_LIB__IMPL__I_2_HPP
#define SOME_LIB__IMPL__I_2_HPP

#include
#include

namespace some_lib {
namespace impl
{
class I_2
{
public:
// интерфейс
private:
std::vector data_;
};
}}

#endif

Файл some_lib/impl/A_1.hpp
#ifndef SOME_LIB__A_1_HPP
#define SOME_LIB__A_1_HPP

#include
#include
#include
#include

namespace some_lib
{
class A_1
{
public:
// интерфейс
private:
impl::I_1 a_;
std::list data_;
std::vector another_data_;
};
}

#endif

Файл some_lib/impl/A_2.hpp
#ifndef SOME_LIB__A_2_HPP
#define SOME_LIB__A_2_HPP

#include
#include
#include

namespace some_lib
{
class A_2
{
public:
A_2( std::string const& );
private:
impl::I_2 a_;
};
}

#endif

Файл some_lib/impl/A_3.hpp
#ifndef SOME_LIB__A_3_HPP
#define SOME_LIB__A_3_HPP

#include
#include
#include
#include

namespace some_lib
{
class A_3
{
public:
A_3( std::string const& );
void func( A_2& );
private:
impl::I_2 a_;
std::string name_;
};
}
#endif

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

Замечания по реализации тестовой библиотеки

Для незначительного сокращения кода вместо стандартно применяемых «стражей» включения и реализующихся через #ifndef, #define, #endif, в заголовочных файлах можно использовать #pragma once. Но данный способ работает не на всех компиляторах и, поэтому, не всегда применим.
Наша библиотека содержит относительно простую схему связей между элементами. Во что выливается разработчику библиотеки реализация более сложных зависимостей представить нетрудно.
Стоит отметить еще один интересный момент. При включении только одного заголовочного файла some_lib/A_3.hpp пользователь фактически подключает больше половины библиотеки (если быть более точным, то 4/6 исходных файлов).
А если теперь задаться вопросом: действительно ли, так необходимо реализовывать для пользователя библиотеки возможность подключения отдельных ее элементов?
Основным аргументом в пользу ответа «Да» будет то, что данный подход позволит сократить время компиляции при подключении отдельных элементов по сравнению с временем компиляции при полном включении всех элементов библиотеки. Если связей между элементами библиотеки почти нет (не наш случай), то это действительно так. А если связей немало — ответ неоднозначный. При обдумывании ответа стоит вспомнить о том, что «стражи» включения и директивы #include на этапе обработки исходных файлов препроцессором в ходе компиляции имеют ненулевые временные издержки.
Предположим, что ответ на этот вопрос «Нет». Вот здесь-то и начинается самое интересное…
Реализация тестовой библиотеки, использующей единственную точку подключения

Пользователю для подключения библиотеки теперь необходима всего одна строчка кода:
#include

Теперь остановимся на моментах реализации, которые может применить разработчик библиотеки:
1) Поскольку имеется только одна точка подключения библиотеки (файл some_lib/include.hpp), разработчик библиотеки может избавиться от всех «стражей» включения, кроме одного — в файле подключения всей библиотеки.
2) Каждый файл «пользовательского» класса или класса-элемента реализации теперь не обязан содержать включения файлов, содержащих зависимые элементы.
3) Применение «рабочих» пространств имен позволяет избавиться от задания пространств имен в каждом файле.
Поскольку файл для подключения библиотеки пользователем только один, то можно пересмотреть структуру файлов библиотеки.
Реализация библиотеки теперь может выглядеть следующим образом:
Файл some_lib/include.hpp
#ifndef SOME_LIB__INCLUDE_HPP
#define SOME_LIB__INCLUDE_HPP

#include
#include
#include

#include

namespace z_some_lib
{
using namespace std;
// или
// using std::list;
// using std::vector;
// using std::string;

#include
#include
#include
#include
#include
}

namespace some_lib
{
using z_some_lib::A_1;
using z_some_lib::A_2;
using z_some_lib::A_3;
}

#endif

Файл some_lib/private/config.hpp
#if defined(_MSC_VER)
//...
#elif defined(__GNUC__)
//...
#else
//...
#endif

Файл some_lib/private/I_1.hpp
class I_1
{
public:
void func( vector const& );
private:
// члены-данные
};

Файл some_lib/private/I_2.hpp
class I_2
{
public:
// интерфейс
private:
vector data_;
};

Файл some_lib/public/A_1.hpp
class A_1
{
public:
// интерфейс
private:
I_1 a_;
list data_;
vector another_data_;
};

Файл some_lib/public/A_2.hpp
class A_2
{
public:
A_2( string const& );
private:
I_2 a_;
};

Файл some_lib/public/A_3.hpp
class A_3
{
public:
A_3( string const& );
void func( A_2& );
private:
I_2 a_;
string name_;
};

Заключение

Описывать достоинства и недостатки представленного подхода к реализации, думаю, нет смысла — код сам говорит за себя. Каждый разработчик самостоятельно решит какую схему при реализации кода библиотеки он для себя выберет, взвесив при этом все «за» и «против».
А если внимательно присмотреться к представленной схеме, то есть ощущение чего-то знакомого. Но об этом не сейчас…


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js