Перейти к содержимому
Фёдор Башинский

Мост

5 min read
Обложка статьи «Мост»

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

Проблема

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

interface DynoStand
{
    public function measure(string $model): int; // л.с.
}
 
final class DynoStandConnection implements DynoStand
{
    public function measure(string $model): int
    {
        // Измерение через собственное физическое соединение со стендом.
        // Класс объявлен final — соединение с физическим устройством
        // должно быть одно на всё приложение, наследоваться от него нельзя.
    }
}
 
// Класс из стороннего SDK — с другим именем метода и в других единицах
// измерения (киловатты вместо лошадиных сил). Редактировать его нельзя.
final class LegacyDynoDevice
{
    public function readPowerKw(string $carModel): float
    {
        // ...
    }
}
 
class LegacyDynoAdapter implements DynoStand
{
    public function __construct(
        private readonly LegacyDynoDevice $legacyDevice,
    ) {}
 
    public function measure(string $model): int
    {
        $kilowatts = $this->legacyDevice->readPowerKw($model);
 
        return (int) round($kilowatts * 1.35962); // кВт → л.с.
    }
}

Теперь нужно добавить не варианты стендов, а варианты процедур диагностики: быстрая проверка (превышает ли мощность порог) и полная диагностика (несколько замеров подряд). Первая мысль — расширить существующие классы стендов наследованием:

class QuickCheckDiagnostic extends DynoStandConnection
{
    public function run(string $model): bool
    {
        return $this->measure($model) > 50;
    }
}
 
class FullDiagnostic extends DynoStandConnection
{
    public function run(string $model): array
    {
        $samples = [];
        for ($i = 0; $i < 5; $i++) {
            $samples[] = $this->measure($model);
        }
        return $samples;
    }
}

Здесь сразу обнаруживается жёсткое препятствие: DynoStandConnection объявлен final — унаследоваться от него нельзя в принципе, код просто не скомпилируется. Но даже если бы это было возможно, проблема глубже: нужна ещё и версия каждой процедуры для LegacyDynoAdapter. QuickCheckDiagnostic и его аналог для легаси-стенда будут отличаться только родительским классом, а логика внутри — дублироваться один в один. Появится третий вендор стенда — количество классов вырастет не на один, а на количество уже существующих процедур: два измерения (тип процедуры и тип стенда) перемножаются, а не складываются.

Решение

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

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

abstract class DiagnosticProcedure
{
    public function __construct(
        protected readonly DynoStand $stand,
    ) {}
 
    abstract public function run(string $model): mixed;
}

И её конкретные варианты — каждый описан один раз, независимо от того, какой стенд будет использован:

class QuickCheckProcedure extends DiagnosticProcedure
{
    private const SAFE_THRESHOLD = 50;
 
    public function run(string $model): bool
    {
        return $this->stand->measure($model) > self::SAFE_THRESHOLD;
    }
}
 
class FullDiagnosticProcedure extends DiagnosticProcedure
{
    private const SAMPLES = 5;
 
    public function run(string $model): array
    {
        $samples = [];
 
        for ($i = 0; $i < self::SAMPLES; $i++) {
            $samples[] = $this->stand->measure($model);
        }
 
        return $samples;
    }
}

Стенд передаётся в конструктор, поэтому любую процедуру можно выполнить на любом стенде без единого дополнительного класса:

$quickOnOwnStand = new QuickCheckProcedure(new DynoStandConnection());
$quickOnLegacyStand = new QuickCheckProcedure(new LegacyDynoAdapter(new LegacyDynoDevice()));
 
$fullOnOwnStand = new FullDiagnosticProcedure(new DynoStandConnection());

Появится третий вендор стенда — он реализует DynoStand, и обе процедуры сразу становятся с ним совместимы без единой новой строчки в QuickCheckProcedure или FullDiagnosticProcedure. Появится третий вид процедуры — она сразу работает с любым существующим и будущим стендом.

Из чего состоит паттерн

  • Абстракция (DiagnosticProcedure) — определяет высокоуровневый интерфейс для клиента и хранит ссылку на объект реализации.
  • Уточнённая абстракция (QuickCheckProcedure, FullDiagnosticProcedure) — расширяет абстракцию конкретной бизнес-логикой, не заботясь о том, какой именно стенд используется.
  • Интерфейс реализации (DynoStand) — низкоуровневый интерфейс, которым пользуется абстракция.
  • Конкретная реализация (DynoStandConnection, LegacyDynoAdapter) — конкретный способ выполнить операцию, объявленную в интерфейсе реализации.

Мост vs Адаптер

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

АдаптерМост
Когда проектируетсяПо факту, когда уже есть несовместимый классЗаранее, до появления реализаций
Что решаетОдин конкретный конфликт интерфейсовДве независимые иерархии, которые должны расти отдельно
Направление зависимостиПриспосабливает существующее под целевой интерфейсОбе стороны с самого начала спроектированы как независимые

LegacyDynoAdapter в примере выше — это как раз Адаптер: он приводит несовместимый класс LegacyDynoDevice к общему интерфейсу DynoStand. Мост в этом примере использует уже приведённый к общему виду интерфейс как одну из двух независимых иерархий — оба паттерна прекрасно работают вместе, решая разные задачи на разных уровнях.

Когда применять

  • Есть два (или больше) независимых измерения, каждое из которых нужно расширять отдельно — как тип процедуры и тип стенда.
  • Наследование грозит комбинаторным взрывом классов «на каждое сочетание».
  • Реализацию нужно менять в рантайме, а не фиксировать один раз при определении класса, как это происходит при наследовании.
  • Один из классов, который хотелось бы унаследовать, объявлен final или иным образом закрыт для наследования — композиция в такой ситуации не альтернатива, а единственный рабочий вариант.

Плюсы и минусы

Мост устраняет комбинаторный взрыв классов, позволяет обеим иерархиям развиваться независимо и даёт возможность подменять реализацию в рантайме — то, что наследование в принципе не может предложить, поскольку родительский класс фиксируется на этапе объявления.

Минус — цена этой гибкости платится заранее: нужно спроектировать интерфейс реализации ещё до того, как станет понятно, действительно ли понадобится больше одного измерения вариации. Если в системе всего один стенд и одна процедура, введение абстракции и интерфейса реализации — это сложность, которая пока ничем не оправдана.

Итог

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