Привет! Меня зовут Николай, я C++‑разработчик в SimbirSoft. Я подготовил цикл статей, в которых хочу поделиться своим опытом проектирования библиотек на примере решения геометрических задач и разобрать три популярных подхода к организации кода. В этой статье я расскажу про классическое наследование с виртуальными функциями и использование шаблонов. В продолжении будет еще подход, следи за обновлениями профиля.
Статья будет полезна разработчикам C++, которые сталкиваются с выбором архитектурных решений при создании расширяемых и производительных библиотек. Мы рассмотрим практические примеры реализации обоих подходов, сравним их преимущества и ограничения, а также обсудим компромиссы, которые неизбежно возникают при проектировании масштабируемых решений.
Для комфортного чтения материала потребуется уверенное владение базовым синтаксисом C++, понимание принципов объектно-ориентированного программирования, а также общее представление о шаблонах и виртуальных функциях. Текст ориентирован на разработчиков среднего уровня, но может быть полезен и тем, кто только начинает глубже разбираться в архитектуре C++-библиотек.
Проектирование библиотеки на С++
При создании программного обеспечения часто возникает задача разработки общей библиотеки для всего проекта. В зависимости от требований проекта такая библиотека может содержать наиболее общие функции, необходимые для решения поставленных задач. При проектировании подобной библиотеки могут применяться различные подходы. В данной статье рассматриваются некоторые из них на примере решения геометрических задач.
Описание задачи
Существует три геометрических объекта: прямая, луч и отрезок (далее в статье все эти объекты будем называть линиями). Для каждого из них необходимо реализовать следующие методы:
проверка, находится ли точка на линии;
построение прямой, параллельной данной линии;
определение точки пересечения линий (если такая существует).
Наследование и виртуальные функции

Рассмотрим первый вариант реализации такой библиотеки с использованием наследования и виртуальных функций. В основе данного подхода будет интерфейсный класс, от которого будут наследоваться остальные классы.
Реализуем интерфейсный класс линии:
class ILine{ public: virtual ~ILine() = default; virtual std::array<double,3> equation() const = 0; virtual bool isPointLine(const Point &point) const = 0; virtual std::unique_ptr<ILine> parallelLine(double distance) const = 0; virtual std::optional<Point> intersectionsLine(const std::shared_ptr<ILine> &line) const = 0; virtual bool pointBelongsToArea(const Point &point) const = 0; };
Помимо ранее перечисленных методов, в интерфейсе присутствуют дополнительные методы, необходимые для решения поставленных задач:
equation — получение коэффициентов уравнения прямой;
pointBelongsToArea — метод определения принадлежности точки области линии (данный метод необходим для проверки принадлежности точки отрезку или лучу).
Для упрощения опишем класс точки в примитивном виде.
struct Point{ double x; double y; };
Реализация класса Прямая:
class StraightLine : public ILine{ public: StraightLine() : ILine(){} StraightLine(double a, double b, double c) : ILine(), a_(a), b_(b), c_(c){} std::array<double,3> equation() const override{ return {a_,b_,c_}; } //Для упрощения кода, здесь и в дальше будет использоваться знак равенства для чисел с плавающей запятой. bool isPointLine(const Point &point) const override{ return (a_ * point.x + b_ * point.y + c_ == 0); } std::unique_ptr<ILine> parallelLine(double distance) const override{ return std::make_unique<StraightLine>(a_, b_, c_ + distance * std::sqrt(a_ * a_ + b_ * b_)); } std::optional<Point> intersectionsLine(const std::shared_ptr<ILine> &line) const override{ auto [a,b,c] = line->equation(); if(const auto d = a_ * b - a * b_; d != 0){ Point point{(b_ * c - b * c_) / d, (c_ * a - c * a_) / d}; if(pointBelongsToArea(point) && line->pointBelongsToArea(point)){ return point; } } return std::nullopt; } //Данная функция всегда возвращает true, т.к. для прямой точка всегда находиться в области линии bool pointBelongsToArea(const Point &point) const override{ return true; } private: //Прямая характеризуется тремя параметрами, которые являются коэффициентами уравнения double a_{}, b_{}, c_{}; };
Реализация класса Луч:
class HalfLine : public ILine{ public: HalfLine() : ILine(){} HalfLine(const Point &start, double angle) : ILine(), start_(start), angle_(angle){} std::array<double,3> equation() const override{ const auto a = -std::cos(angle_); const auto b = std::sin(angle_); return {a, b, -b * start_.y - a * start_.x}; } bool isPointLine(const Point &point) const override{ auto [a,b,c] = equation(); return (a * point.x + b * point.y + c == 0) && pointBelongsToArea(point); } std::unique_ptr<ILine> parallelLine(double distance) const override{ auto [a,b,c] = equation(); return std::make_unique<StraightLine>(a, b, c + distance * std::sqrt(a * a + b * b)); } std::optional<Point> intersectionsLine(const std::shared_ptr<ILine> &line) const override{ auto [a1,b1,c1] = equation(); auto [a2,b2,c2] = line->equation(); if(const auto d = a1 * b2 - a2 * b1; d != 0){ Point point{(b1 * c2 - b2 * c1) / d, (c1 * a2 - c2 * a1) / d}; if(pointBelongsToArea(point) && line->pointBelongsToArea(point)){ return point; } } return std::nullopt; } bool pointBelongsToArea(const Point &point) const override{ double angle{}; double d = std::numbers::pi / 2.; if((std::exchange(angle, angle + d) <= angle_) && (angle_ < angle)){ return (start_.x <= point.x) && (start_.y <= point.y); } if((std::exchange(angle, angle + d) <= angle_) && (angle_ < angle)){ return (start_.x <= point.x) && (start_.y >= point.y); } if((std::exchange(angle, angle + d) <= angle_) && (angle_ < angle)){ return (start_.x >= point.x) && (start_.y >= point.y); } if((std::exchange(angle, angle + d) <= angle_) && (angle_ < angle)){ return (start_.x >= point.x) && (start_.y <= point.y); } return false; } private: //Луч характеризуется двумя параметрами, это начальной точкой и направлением луча в радианах Point start_; double angle_{}; };
Реализация класса Отрезок:
class LineSection : public ILine{ public: LineSection() : ILine(){} LineSection(const Point &start, const Point &stop) : ILine(), start_(start), stop_(stop){} std::array<double,3> equation() const override{ const auto a = start_.y - stop_.y; const auto b = stop_.x - start_.x; return {a, b, -b * start_.y - a * start_.x}; } bool isPointLine(const Point &point) const override{ auto [a,b,c] = equation(); return (a * point.x + b * point.y + c == 0) && pointBelongsToArea(point); } std::unique_ptr<ILine> parallelLine(double distance) const override{ auto [a,b,c] = equation(); return std::make_unique<StraightLine>(a, b, c + distance * std::sqrt(a * a + b * b)); } std::optional<Point> intersectionsLine(const std::shared_ptr<ILine> &line) const override{ auto [a1,b1,c1] = equation(); auto [a2,b2,c2] = line->equation(); if(const auto d = a1 * b2 - a2 * b1; d != 0){ Point point{(b1 * c2 - b2 * c1) / d, (c1 * a2 - c2 * a1) / d}; if(pointBelongsToArea(point) && line->pointBelongsToArea(point)){ return point; } } return std::nullopt; } bool pointBelongsToArea(const Point &point) const override{ bool flag_x = (start_.x < stop_.x) ? ((start_.x <= point.x) && (point.x <= stop_.x)) : ((stop_.x <= point.x) && (point.x <= start_.x)); bool flag_y = (start_.y < stop_.y) ? ((start_.y <= point.y) && (point.y <= stop_.y)) : ((stop_.y <= point.y) && (point.y <= start_.y)); return flag_x && flag_y; } private: //Отрезок характеризуется двумя параметрами, это начальной точкой и конечной точкой Point start_; Point stop_; };
Если рассмотреть код, можно заметить, что методы isPointLine, parallelLine и intersectionsLine идентичны по реализации для каждого класса. Следовательно, их можно вынести за пределы классов в виде отдельных функций.
Интерфейсный класс будет переписан следующим образом:
class ILine{ public: virtual ~ILine() = default; virtual std::array<double,3> equation() const = 0; virtual bool pointBelongsToArea(const Point &point) const = 0; };
Следовательно, из классов StraightLine, HalfLine и LineSection эти методы также необходимо убрать. Их реализация будет выглядеть следующим образом:
bool isPointLine(const std::shared_ptr<ILine> &line, const Point &point){ auto [a,b,c] = line->equation(); return (a * point.x + b * point.y + c == 0) && line->pointBelongsToArea(point); } std::unique_ptr<ILine> parallelLine(const std::shared_ptr<ILine> &line, double distance){ auto [a,b,c] = line->equation(); return std::make_unique<StraightLine>(a, b, c + distance * std::sqrt(a * a + b * b)); } std::optional<Point> intersectionsLine(const std::shared_ptr<ILine> &line1, const std::shared_ptr<ILine> &line2){ auto [a1,b1,c1] = line1->equation(); auto [a2,b2,c2] = line2->equation(); if(const auto d = a1 * b2 - a2 * b1; d != 0){ Point point{(b1 * c2 - b2 * c1) / d, (c1 * a2 - c2 * a1) / d}; if(line1->pointBelongsToArea(point) && line2->pointBelongsToArea(point)){ return point; } } return std::nullopt; }
Отступление
Можно было бы перенести все методы в интерфейсный класс, однако для метода parallelLine пришлось бы делать исключение, так как он возвращает объект класса StraightLine, который на момент описания интерфейса ему не известен. В таком случае необходимо возвращать не сам объект, а только его параметры (например, коэффициенты уравнения прямой).

Подход с использованием наследования выглядит наглядным и компактным, однако он обладает ограниченной расширяемостью по следующим причинам:
для добавления новых методов требуется модификация интерфейсного класса;
для относительно простых задач используется динамический полиморфизм;
при использовании данной реализации можно работать только с теми классами, которые изначально предусмотрены библиотекой;
при добавлении новых классов аналогичного типа (например, линии с другими характеристиками) необходимо дублировать реализацию функций isPointLine, parallelLine и intersectionsLine;
присутствует жёсткая привязка к типам данных (в данном случае double): параметры классов не задаются через шаблоны. При необходимости можно реализовать шаблонные классы, например, следующим образом:
template<typename Type> class StraightLine : public ILine{ //... private: Type a_{}, b_{}, c_{}; }; template<typename Type, template<class> class CPoint> class HalfLine : public ILine{ public: //... private: CPoint<Type> start_; Type angle_; }; template<typename Type, template<class> class CPoint> class LineSection : public ILine{ public: //... private: CPoint<Type> start_; CPoint<Type> stop_; };
Но это не решит проблему с параметрами виртуальных функций, т.к. известно нельзя создать виртуальную шаблонную функцию.
6. система координат точек. Нельзя расширять функционал, если у отрезка и луча разные системы координат.
Подход с использованием шаблонов

Данный подход характеризуется применением шаблонных классов и функций с шаблонными параметрами.
Для начала перепишем классы, используя шаблонные параметры.
Структура точки:
template<typename Type> struct Point{ Type x; Type y; };
Структура Прямой:
template<typename Type> struct StraightLine{ Type a{}, b{}, c{}; };
Структура Луча:
template<typename Type> struct HalfLine{ Point<Type> start; Type angle; };
Структура Отрезка:
template<typename Type> struct LineSection{ Point<Type> start; Point<Type> stop; };
Соответственно реализация методов equation и pointBelongsToArea для линий будет выглядеть следующим образом:
template<typename Type> constexpr std::array<Type,3> equation(const StraightLine<Type> &line){ return {line.a, line.b, line.c}; } template<typename Type> constexpr std::array<Type,3> equation(const HalfLine<Type> &line){ const auto a = -std::cos(line.angle); const auto b = std::sin(line.angle); return {a, b, -b * line.start.y - a * line.start.x}; } template<typename Type> constexpr std::array<Type,3> equation(const LineSection<Type> &line){ const auto a = line.start.y - line.stop.y; const auto b = line.stop.x - line.start.x; return {a, b, -b * line.start.y - a * line.start.x}; } template<typename Type> constexpr bool pointBelongsToArea(const StraightLine<Type> &, const Point<Type> &){ return true; } template<typename Type> constexpr bool pointBelongsToArea(const HalfLine<Type> &line, const Point<Type> &point){ Type angle{}; double d = std::numbers::pi / 2.; if((std::exchange(angle, angle + d) <= line.angle) && (line.angle < angle)){ return (line.start.x <= point.x) && (line.start.y <= point.y); } if((std::exchange(angle, angle + d) <= line.angle) && (line.angle < angle)){ return (line.start.x <= point.x) && (line.start.y >= point.y); } if((std::exchange(angle, angle + d) <= line.angle) && (line.angle < angle)){ return (line.start.x >= point.x) && (line.start.y >= point.y); } if((std::exchange(angle, angle + d) <= line.angle) && (line.angle < angle)){ return (line.start.x >= point.x) && (line.start.y <= point.y); } return false; } template<typename Type> constexpr bool pointBelongsToArea(const LineSection<Type> &line, const Point<Type> &point){ bool flag_x = (line.start.x < line.stop.x) ? ((line.start.x <= point.x) && (point.x <= line.stop.x)) : ((line.stop.x <= point.x) && (point.x <= line.start.x)); bool flag_y = (line.start.y < line.stop.y) ? ((line.start.y <= point.y) && (point.y <= line.stop.y)) : ((line.stop.y <= point.y) && (point.y <= line.start.y)); return flag_x && flag_y; }
Отступление
С помощью if constexpr можно объединить реализацию функций equation в одну, следующим образом:
template<typename Type, template<class> class Line> constexpr std::array<Type,3> equation(const Line<Type> &line){ if constexpr(std::is_same_v<Line<Type>, StraightLine<Type>>){ //... } else if constexpr(std::is_same_v<Line<Type>, HalfLine<Type>>){ //... } else if constexpr(std::is_same_v<Line<Type>, LineSection<Type>>){ //... } else{ static_assert(false, "Not Line"); } }
При этом необходимо проверять входной тип Line. Такое объединение возможно только при реализации структур линий с одинаковыми шаблонными параметрами, как показано в приведённом примере. В общем случае подобное объединение невозможно.
Шаблонная реализация функций isPointLine, parallelLine и intersectionsLine будет выглядеть следующим образом:
template<typename Line, typename Type> constexpr bool isPointLine(const Line &line, const Point<Type> &point){ auto [a,b,c] = equation(line); return (a * point.x + b * point.y + c == 0) && pointBelongsToArea(line, point); } template<typename Line, typename Type> constexpr StraightLine<Type> parallelLine(const Line &line, Type distance){ auto [a,b,c] = equation(line); return {a, b, c + distance * std::sqrt(a * a + b * b)}; } template<typename Type, typename Line1, typename Line2> constexpr std::optional<Point<Type>> intersectionsLine(const Line1 &line1, const Line2 &line2){ auto [a1,b1,c1] = equation(line1); auto [a2,b2,c2] = equation(line2); if(const auto d = a1 * b2 - a2 * b1; d != 0){ Point point{(b1 * c2 - b2 * c1) / d, (c1 * a2 - c2 * a1) / d}; if(pointBelongsToArea(line1, point) && pointBelongsToArea(line2, point)){ return point; } } return std::nullopt; }
Данный подход позволяет решить следующие проблемы:
отсутствует жёсткая привязка к типам данных;
отсутствует интерфейсный класс, что избавляет от необходимости его модификации;
не используется динамический полиморфизм.

При этом подход не полностью решает проблему использования сторонних классов, поскольку можно реализовать аналогичный класс с несовпадающими параметрами, и в таком случае шаблонные функции могут с ним не работать. Также остаётся нерешённой проблема, если классы отличаются по шаблонным параметрам (например, используются разные системы координат или размерные величины).
Хотя реализация с использованием шаблонов не решает все задачи, она наводит на идеи возможных решений. Так, при наличии собственной реализации класса линии для использования существующих функций можно написать класс-адаптер, преобразующий этот класс в тип, принимаемый функциями.
Отступление
Можно было отказаться от реализации структур линий и передавать в функции непосредственно параметры.
Например:
template<typename Type> constexpr std::array<Type,3> equation(Type a, Type b, Type c){ //... } template<typename Type, template<class> class CPoint, typename Angle> constexpr std::array<Type,3> equation(const CPoint<Type> &start, const Angle &angle){ //... } template<typename Type, template<class> class CPoint> constexpr std::array<Type,3> equation(const CPoint<Type> &start, const CPoint<Type> &stop){ //... }
Однако при реализации функции intersectionsLine возникли бы сложности, поскольку потребовалось бы определять тип передаваемой линии по количеству параметров (в случае, если функция intersectionsLine должна быть единственной). Альтернативным вариантом является создание множества перегруженных функций, однако и в этом случае необходимо учитывать ситуацию, когда количество шаблонных параметров у разных функций совпадает. В итоге использование структур представляется более предпочтительным решением.
В первой части статьи были рассмотрены два подхода к проектированию библиотек, каждый из которых имеет свои преимущества и недостатки. В следующей статье будет рассмотрен подход, позволяющий писать обобщённый код и устранить выявленные недостатки.
