Программа курса← Объектно ориентированное программирование
1История ООП
2Объект и Класс
3Базовые концепции ООП
4Принцип проектирования GRASP
5Принцип проектирования SOLID
6Паттерны GOF
7Методические материалы

Low Coupling (Слабое зацепление)

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

Каким бывает класс со слабым зацеплением

  • имеет слабую зависимость от других классов;
  • не зависит от внешних изменений: правка в одном классе слабо влияет на остальные;
  • прост для повторного использования.

Как выглядит сильное зацепление

Сервис отчётов, который всё делает сам:

// Сервис сам создаёт соединение и сам знает, каким SQL писать отчёт
public class ReportService
{
    private readonly PostgresConnection _db = new("host=localhost;db=reports");

    public void Publish(Report report)
    {
        report.MarkReady();
        _db.ExecuteSql(
            "INSERT INTO reports (title, body) VALUES (@title, @body)",
            report.Title, report.Body);
    }
}
// Сервис сам создаёт соединение и сам знает, каким SQL писать отчёт
class ReportService {
public:
    void Publish(Report& report) {
        report.MarkReady();
        db_.ExecuteSql("INSERT INTO reports (title, body) VALUES ($1, $2)",
                       report.title(), report.body());
    }

private:
    PostgresConnection db_{"host=localhost dbname=reports"};
};
// Сервис сам создаёт соединение и сам знает, каким SQL писать отчёт
type ReportService struct {
	db *PostgresConnection
}

func NewReportService() *ReportService {
	return &ReportService{db: OpenPostgres("host=localhost dbname=reports")}
}

func (s *ReportService) Publish(report *Report) error {
	report.MarkReady()

	return s.db.ExecuteSQL(
		"INSERT INTO reports (title, body) VALUES ($1, $2)",
		report.Title, report.Body,
	)
}
# Сервис сам создаёт соединение и сам знает, каким SQL писать отчёт
class ReportService:
    def __init__(self) -> None:
        self._db = PostgresConnection("host=localhost dbname=reports")

    def publish(self, report: Report) -> None:
        report.mark_ready()
        self._db.execute_sql(
            "INSERT INTO reports (title, body) VALUES (%s, %s)",
            report.title,
            report.body,
        )
// Сервис сам создаёт соединение и сам знает, каким SQL писать отчёт
class ReportService {
    private readonly db = new PostgresConnection('host=localhost db=reports');

    publish(report: Report): void {
        report.markReady();
        this.db.executeSql('INSERT INTO reports (title, body) VALUES ($1, $2)', report.title, report.body);
    }
}
// Сервис сам создаёт соединение и сам знает, каким SQL писать отчёт
public class ReportService {
    private final PostgresConnection db = new PostgresConnection("host=localhost;db=reports");

    public void publish(Report report) {
        report.markReady();
        db.executeSql(
                "INSERT INTO reports (title, body) VALUES (?, ?)",
                report.getTitle(), report.getBody());
    }
}
// Сервис сам создаёт соединение и сам знает, каким SQL писать отчёт
class ReportService {
    private val db = PostgresConnection("host=localhost db=reports")

    fun publish(report: Report) {
        report.markReady()
        db.executeSql(
            "INSERT INTO reports (title, body) VALUES ($1, $2)",
            report.title, report.body,
        )
    }
}

Класс небольшой, а зацеплен сразу с тремя вещами: с конкретной СУБД, с её диалектом SQL и со строкой подключения. Что из этого выйдет:

  • тест не написать – соединение создаётся внутри конструктора, подменить его нечем, и любой прогон идёт в живую базу;
  • переезд на другую СУБД правит сервис – SQL лежит здесь же, хотя к публикации отчёта отношения не имеет;
  • сервис не переиспользовать – вместе с ним в любой проект приезжает драйвер Postgres;
  • логику не видно за деталями – суть метода в двух словах «пометить и сохранить», но разглядеть их между строкой подключения и SQL уже сложно.

Как развязать

Сервису нужно не соединение, а возможность сохранить отчёт. Ровно это и выносим в интерфейс, а конкретную реализацию передаём снаружи:

// Сервис знает только про интерфейс хранилища
public interface IReportStorage
{
    void Save(Report report);
}

public class ReportService
{
    private readonly IReportStorage _storage;

    public ReportService(IReportStorage storage) => _storage = storage;

    public void Publish(Report report)
    {
        report.MarkReady();
        _storage.Save(report);
    }
}
// Сервис знает только про интерфейс хранилища
class IReportStorage {
public:
    virtual ~IReportStorage() = default;
    virtual void Save(const Report& report) = 0;
};

class ReportService {
public:
    explicit ReportService(IReportStorage& storage) : storage_(storage) {}

    void Publish(Report& report) {
        report.MarkReady();
        storage_.Save(report);
    }

private:
    IReportStorage& storage_;
};
// Сервис знает только про интерфейс хранилища
type ReportStorage interface {
	Save(report *Report) error
}

type ReportService struct {
	storage ReportStorage
}

func NewReportService(storage ReportStorage) *ReportService {
	return &ReportService{storage: storage}
}

func (s *ReportService) Publish(report *Report) error {
	report.MarkReady()

	return s.storage.Save(report)
}
# Сервис знает только про интерфейс хранилища
class ReportStorage(Protocol):
    def save(self, report: Report) -> None: ...

class ReportService:
    def __init__(self, storage: ReportStorage) -> None:
        self._storage = storage

    def publish(self, report: Report) -> None:
        report.mark_ready()
        self._storage.save(report)
// Сервис знает только про интерфейс хранилища
interface ReportStorage {
    save(report: Report): void;
}

class ReportService {
    constructor(private readonly storage: ReportStorage) {}

    publish(report: Report): void {
        report.markReady();
        this.storage.save(report);
    }
}
// Сервис знает только про интерфейс хранилища
public interface ReportStorage {
    void save(Report report);
}

public class ReportService {
    private final ReportStorage storage;

    public ReportService(ReportStorage storage) {
        this.storage = storage;
    }

    public void publish(Report report) {
        report.markReady();
        storage.save(report);
    }
}
// Сервис знает только про интерфейс хранилища
interface ReportStorage {
    fun save(report: Report)
}

class ReportService(private val storage: ReportStorage) {
    fun publish(report: Report) {
        report.markReady()
        storage.save(report)
    }
}

Класс стал короче, но выиграли не строки:

  • тест пишется за минуту – вместо базы подставляется хранилище в памяти;
  • СУБД меняется в одном месте – SQL уезжает в PostgresReportStorage, сервис об этом не узнает; так же добавляется кеш, очередь или запись в файл;
  • сервис переиспользуется – он компилируется и работает без драйвера Postgres;
  • видно, что делает метод – пометить отчёт готовым и сохранить, остальное за интерфейсом.

Зацепление не исчезло: сервис по-прежнему зависит от ReportStorage. Но зависимость теперь на устойчивый интерфейс из двух строк, а не на чужую библиотеку – меняться ему причин почти нет. Тот же ход в SOLID называется принципом инверсии зависимостей.