BackendBit

К списку паттернов

Bridge применяется, когда класс необходимо расширить несколькими независимыми (ортогональными) путями.

Да, звучит сложно. Поясним на примере.

Пример

Представим, что у нас есть иерархия классов, которые моделируют работу компьютеров. У нас есть абстрактный класс Computer и абстрактный класс-наследник PC (настольный компьютер).

Также у нас есть две операционные системы: Windows и Linux. Операционные системы представлены в классах PCWithLinux и PCWithWindows.

package naive;

public abstract class Computer {
    protected final String name;

    protected Computer(String name) {
        this.name = name;
    }

    public abstract void boot();
}
java
package naive;

public abstract class PC extends Computer {
    protected PC(String name) {
        super(name);
    }

    @Override
    public void boot() {
        System.out.println("Booting " + name + "...");
        System.out.println("Pressing the power button on the computer case...");
    }
}
java
package naive;

public final class PCWithLinux extends PC {
    public PCWithLinux(String name) {
        super(name);
    }

    @Override
    public void boot() {
        super.boot();
        bootTheOperatingSystem();
    }

    private void bootTheOperatingSystem() {
        System.out.println("Booting the Linux operating system...");
    }
}
java
package naive;

public final class PCWithWindows extends PC {
    public PCWithWindows(String name) {
        super(name);
    }

    @Override
    public void boot() {
        super.boot();
        bootTheOperatingSystem();
    }

    private void bootTheOperatingSystem() {
        System.out.println("Booting the Windows operating system...");
    }
}
java

Для наглядности покажем иерархию классов на схеме:

UML диаграмма наивной иерархии компьютеровПоказывает абстрактный класс Computer, абстрактный класс PC и конкретные классы PCWithLinux и PCWithWindows.<<abstract>>Computer+boot()<<abstract>>PC+boot()PCWithLinux+boot()PCWithWindows+boot()

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

UML диаграмма комбинаторного взрыва классовПоказывает класс Computer с подклассами PC и Laptop, каждый из которых порождает классы для Linux и Windows – итого четыре конкретных класса.<<abstract>>Computer+boot()<<abstract>>PC+boot()<<abstract>>Laptop+boot()PCWithLinux+boot()PCWithWindows+boot()LaptopWithLinux+boot()LaptopWithWindows+boot()

Мы видим, что для нового класса Laptop нам пришлось создать практически идентичные классы под каждую операционную систему. Каждый раз, когда будет появляться новый вид компьютера, нам придётся создавать по два класса. А если появится новая операционная система, то мы будем вынуждены добавлять по одному классу в каждый вид компьютера. Это выглядит совсем неподдерживаемо.

Проблема в том, что в нашей иерархии на самом деле два концепта – тип компьютера и операционная система. Вместо того чтобы дать этим концептам развиваться независимо, мы смешали их в одну иерархию классов.

Решение – зарефакторить классы в две отдельные иерархии и провести между ними «мост».

UML диаграмма классов паттерна МостПоказывает абстрактный класс Computer с подклассами PC и Laptop, интерфейс OperatingSystem с реализациями Linux и Windows, и связь зависимости между Computer и OperatingSystem.<<abstract>>Computer<<interface>>OperatingSystemLaptopPCWindowsLinuxЭту стрелочку можноназвать мостом междуиерархиями
package computer;

public interface OperatingSystem {
    void boot();
}
java
package computer;

public abstract class Computer {
    protected final String name;
    private final OperatingSystem operatingSystem;

    protected Computer(String name, OperatingSystem operatingSystem) {
        this.name = name;
        this.operatingSystem = operatingSystem;
    }

    public void boot() {
        System.out.println("Booting " + name + "...");
        bootHardware();
        operatingSystem.boot();
    }

    protected abstract void bootHardware();
}
java
package computer;

public final class PC extends Computer {
    public PC(String name, OperatingSystem operatingSystem) {
        super(name, operatingSystem);
    }

    @Override
    protected void bootHardware() {
        System.out.println("Pressing the power button on the computer case...");
    }
}
java
package computer;

public final class Laptop extends Computer {
    public Laptop(String name, OperatingSystem operatingSystem) {
        super(name, operatingSystem);
    }

    @Override
    protected void bootHardware() {
        System.out.println("Opening the laptop lid...");
    }
}
java
package computer;

public final class Linux implements OperatingSystem {
    @Override
    public void boot() {
        System.out.println("Booting the Linux operating system...");
    }
}
java
package computer;

public final class Windows implements OperatingSystem {
    @Override
    public void boot() {
        System.out.println("Booting the Windows operating system...");
    }
}
java

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

// Комбинируем любой компьютер с любой ОС
Computer pc = new PC("Desktop", new Linux());
Computer laptop = new Laptop("ThinkPad", new Windows());

pc.boot();
laptop.boot();
java

Composition over Inheritance

Есть классический принцип – «предпочитайте композицию наследованию». Принцип говорит о том, что наследование во многих случаях неоправданно, и вместо него мы можем выразить связь между двумя классами по-другому – просто передать объект другого класса через конструктор.

Паттерн «Мост» – это ещё один аргумент в пользу этого принципа, поскольку паттерн демонстрирует сложности, которые может доставить неправильное применение наследования.

Реальные примеры

JDBC

JDBC – стандартный API Java для работы с базами данных. Его архитектура разделяет ту же идею, что и паттерн Bridge – отделение API от его реализации, чтобы они могли развиваться независимо.

Абстракция – это интерфейсы java.sql.Connection, Statement, ResultSet. Они предоставляют единый интерфейс для работы с любой базой данных. Реализация – это драйверы конкретных СУБД: com.mysql.cj.jdbc.Driver, org.postgresql.Driver и другие.

package real;

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.ResultSet;
import java.sql.Statement;

public class JdbcExample {
    public static void demonstrate() throws Exception {
        // Абстракция: код работает с интерфейсами java.sql.*
        // Реализация: конкретный драйвер извлекается из URL подключения
        try (Connection conn = DriverManager.getConnection(
                "jdbc:postgresql://localhost:5432/mydb", "user", "password");
             Statement stmt = conn.createStatement();
             ResultSet rs = stmt.executeQuery("SELECT id, name FROM users")) {

            while (rs.next()) {
                System.out.println(rs.getInt("id") + ": " + rs.getString("name"));
            }
        }
    }
}
java

Клиентский код работает только с интерфейсами java.sql.* и не зависит от конкретной СУБД. Можно переключиться с PostgreSQL на MySQL, изменив только строку подключения – весь остальной код останется прежним.

Две иерархии развиваются независимо. Разработчики PostgreSQL могут выпустить новую версию драйвера, а разработчики JDK – добавить новые методы в Connection. Эти изменения не влияют друг на друга.

database/sql

Стандартная библиотека Go использует тот же подход для работы с базами данных. Пакет database/sql – это абстракция, а пакет database/sql/driver определяет интерфейс реализации.

package main

import (
    "database/sql"
    "fmt"

    // Драйвер PostgreSQL регистрируется через init()
    _ "github.com/lib/pq"
)

func main() {
    // Абстракция: sql.DB
    // Реализация: драйвер PostgreSQL, зарегистрированный выше
    db, err := sql.Open("postgres", "host=localhost dbname=mydb sslmode=disable")
    if err != nil {
        panic(err)
    }
    defer db.Close()

    rows, err := db.Query("SELECT id, name FROM users")
    if err != nil {
        panic(err)
    }
    defer rows.Close()

    for rows.Next() {
        var id int
        var name string
        if err := rows.Scan(&id, &name); err != nil {
            panic(err)
        }
        fmt.Printf("%d: %s\n", id, name)
    }
    if err := rows.Err(); err != nil {
        panic(err)
    }
}
go

Драйвер регистрирует себя при импорте через функцию init(), вызывая sql.Register(). Это позволяет переключить СУБД, заменив только импорт и строку подключения.