Мост

Мост — это структурный паттерн проектирования, который разделяет один или несколько классов на две независимые иерархии — абстракцию и реализацию! — позволяя изменять их независимо друг от друга.
Проблема
Допустим, есть сервис диагностики двигателя, который умеет измерять мощность через разные физические стенды. Есть общий интерфейс и две реализации: собственный стенд и стенд стороннего производителя с другим 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или иным образом закрыт для наследования — композиция в такой ситуации не альтернатива, а единственный рабочий вариант.
Плюсы и минусы
Мост устраняет комбинаторный взрыв классов, позволяет обеим иерархиям развиваться независимо и даёт возможность подменять реализацию в рантайме — то, что наследование в принципе не может предложить, поскольку родительский класс фиксируется на этапе объявления.
Минус — цена этой гибкости платится заранее: нужно спроектировать интерфейс реализации ещё до того, как станет понятно, действительно ли понадобится больше одного измерения вариации. Если в системе всего один стенд и одна процедура, введение абстракции и интерфейса реализации — это сложность, которая пока ничем не оправдана.
Итог
Мост нужен, когда заведомо известно, что у объекта есть два независимых измерения изменения — и оба должны расти без оглядки друг на друга. Если наследование начинает требовать класса на каждую комбинацию двух независимых признаков — это верный сигнал, что нужен Мост.