вторник, 20 октября 2015 г.

[prog.c++] Велосипедостроение: способ разделить интерфейс класса на публичную и приватную части

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

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

class Environment {
public :
   void start();
   void shutdown();
   void join();

   templatetypename AGENT, typename... ARGS >
   AGENT * start_agent( ARGS &&... args );

   templatetypename DISPATCHER, typename... ARGS >
   DISPATCHER * start_dispatcher( ARGS &&... args );
   ...
};

Есть так же публичные классы Agent и Dispatcher, которые хранят в себе ссылку на Environment:

class Agent {
   Environment & m_env;
public :
   Agent( Environment & env ) : m_env{ env } {}
   ...
};

class Dispatcher {
   Environment & m_env;
public :
   Dispather( Environment & env ) : m_env{ env } {}
   ...
};

И вот в своей работе Agent-ам и Dispatcher-ам потребовалось обращаться к методам Environment, которые не должны быть доступны пользователю.

Например, появляется класс MessageTracer. Его задача -- осуществлять трассировку того, как Agent-ы и Dispatcher-ы выполняют обработку сообщений. Экземпляр MessageTracer может создаваться в конструкторе Environment-а и храниться внутри Environment-а. В своем публичном интерфейсе Environment не предоставляет метода для доступа к MessageTracer-у, т.к. эта часть внутренней кухни и пользователя нужно держать от этой кухни подальше. Не только для того, чтобы пользователь своими шаловливыми ручками чего-нибудь не сломал. Но и для того, чтобы пользователю не пришлось переделывать свой код, если со временем MessageTracer будет заменен на какой-нибудь InternalsTracer и StatisticsCollector.

Итак, грубо говоря, класс Environment принимает вид:

class Environment {
public :
   void start();
   void shutdown();
   void join();

   templatetypename AGENT, typename... ARGS >
   AGENT * start_agent( ARGS &&... args );

   templatetypename DISPATCHER, typename... ARGS >
   DISPATCHER * start_dispatcher( ARGS &&... args );
   ...

private :
   MessageTracer * m_msg_tracer;
};

И задача сводится к тому, чтобы дать классам Agent и Dispatcher возможность обращаться к Environment::m_msg_tracer.

Первое, что приходит в голову -- это сделать Agent и Dispatcher френдами для Environment:

class Environment {
   friend class Agent;
   friend class Dispatcher;
public :

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

Во-вторых, класс Dispatcher легко объявить френдом для Environment, но вот наследники Dispatcher френдами для Environment уже не будут. Что ведет нас либо к необходимости включать наследников Dispatcher в список друзей Environment индивидуально, либо же к необходимости в Dispatcher делать набор методов для доступа к внутренностям Environment-а, вроде вот такого:

class Dispatcher {
   Environment & m_env;

protected :
   MessageTracer * msg_tracer() { return m_env.m_msg_tracer; }

public :
   Dispather( Environment & env ) : m_env{ env } {}
   ...
};

К счастью, такая проблема с разделением интерфейса Environment на открытую и закрытие части возникает не часто. Но когда она возникает, хочется использовать что-то красивое, что не выглядело бы, как набор костылей.

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

namespace impl {
   class InternalEnvironmentIface;
}

class Environment {
   friend class impl::InternalEnvironmentIface;
public :
   void start();
   void shutdown();
   void join();
   ...
};

Сам же класс InternalEnvironmentIface определен в заголовочном файле, лежащем в потрохах библиотеки (т.е. он не часть публичного интерфейса библиотеки). При этом InternalEnvironmentIface является очень простой оберткой вокруг ссылки на Environment:

class InternalEnvironmentIface {
   Environment & m_env;
public :
   InternalEnvironmentIface( Environment & env ) : m_env{ env } {}

   inline MessageTracer * msg_tracer() { return m_env.m_msg_tracer; }
};

И используется он вот таким образом:

auto tracer = InternalEnvironmentIface{m_env}.msg_tracer();

Что дает возможность оптимизирующему компилятору свести все это дело к простому обращению к полю класса Environment.

Вот такой простой способ.

Одним из его достоинств является то, что приватный интерфейс Environment-а можно нарезать на более тонкие ломтики. Так, наследникам Agent может быть доступен один набор приватных методов/полей Environment, а наследникам Dispatcher -- другой. Можно сделать несколько классов, скажем, InternalEnvironmentIfaceForAgents и InternalEnvironmentIfaceForDispatchers, со своими наборами методов.

Еще этот способ удобен в случае, когда Environment не является простым классом. А, например, реализуется посредством идиомы PImpl. Т.е. когда детали Environment вообще скрыты от всех:

class Environment {
   friend class impl::InternalEnvironmentIface;
public :
   void start();
   void shutdown();
   void join();
   ...

   templatetypename AGENT, typename... ARGS >
   AGENT * start_agent( ARGS &&... args );

   templatetypename DISPATCHER, typename... ARGS >
   DISPATCHER * start_dispatcher( ARGS &&... args );
   ...

private :
   struct Implementation;
   std::unique_ptr<Implementation> m_impl;
};

Правда, в этом случае InternalEnvironmentIface уже не будет простой оберткой с inline-овыми методами, детали его реализации так же будут скрыты. Но зато есть возможность реализовать InternalEnvironmentIface там же, где и Environment::Implementation и никому вообще не показывать, как же там все устроено. Что резко сокращает количество зависимостей между частями библиотеки. Правда, ценой дополнительной косвенности (но в случае PImpl-а эту цену всегда нужно платить).

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

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

Комментариев нет: