Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Design principles: SOLID, KISS, DRY, YAGNI, GRASP, LoD, SoC, SLA

SOLID

Принципы SOLID — это набор из пяти принципов проектирования, введенных Робертом Сесилом Мартином (Дядя Боб), cleancoders.com, призванных прояснить изначальные ограничения объектно-ориентированного программирования и сделать программы более гибкими и адаптируемыми.

Принцип единой ответственности (SRP - Single Responsibility Principle)

Принцип единой ответственности (SRP): класс должен иметь только одну ответственность, то есть только изменения в одной части спецификации программного обеспечения должны иметь возможность повлиять на спецификацию класса.

SRP — это про “одну причину для изменения”, а не буквально “одну ответственность” в бытовом смысле.

Роберт Мартин (Uncle Bob) формулировал SRP так:

«У модуля должна быть только одна причина для изменения». Это значит, что ответственность измеряется источником изменений, а не количеством функций в классе.

«Соберите вместе вещи, которые изменяются по одним и тем же причинам. Разделите те вещи, которые изменяются по разным причинам»

Это очень похоже на SoC, не правда ли? Разница между этими двумя принципами в том, что SRP - локальный принцип, обычно применяемый к классам, функциям, модулям, в то время как SoC — стратегический принцип, который работает на всех уровнях архитектуры: от микроскопического (функции) до макроскопического (сервисы, модули, слои).

Принцип единой ответственности обладает всеми преимуществами SoC, в частности, он способствует высокой связности и низкой связанности, а также позволяет избежать анти-шаблона «божественного объекта (God object)», который нарушает SRP, так как в нём сосредоточено слишком много разнотипных причин для изменения (UI, бизнес-логика, доступ к данным и т.д.).



Принцип открытости/закрытости (Open-Closed Principle OCP)

OCP = Расширяем через добавление, а не через изменение.

Мы проектируем код так, чтобы его поведение можно было менять, не переписывая существующие строки. То есть «закрыто» — не значит, что код вообще не трогаем, а что интерфейс стабилен и изменения в нём не ломают существующую работу клиентов.

В функциональном программировании — через высокоуровневые функции, передающие поведение в аргументах.

❌ Чтобы добавить новый способ обработки, нужно лезть внутрь process_list — нарушаем OCP:

fn process_list(nums: Vec<i32>, mode: &str) -> Vec<i32> {
    match mode {
        "square" => nums.iter().map(|x| x * x).collect(),
        "double" => nums.iter().map(|x| x * 2).collect(),
        _ => nums,
    }
}

✅ Теперь мы можем расширять поведение просто передавая новую функцию. Мы не меняем process_list, а расширяем её возможности:

fn process_list<F>(nums: Vec<i32>, f: F) -> Vec<i32>
where
    F: Fn(i32) -> i32,
{
    nums.into_iter().map(f).collect()
}
let doubled = process_list(vec![1, 2, 3], |x| x * 2);
let squared = process_list(vec![1, 2, 3], |x| x * x);

В композиционной архитектуре — через DI (dependency injection), плагины, стратегии, обработчики событий. Поведение расширяется подключением новых модулей, а не изменением старых.

  • Мы не «зашиваем» конкретную реализацию в класс.
  • Класс зависит от абстракции, а не от конкретного типа.
  • Чтобы расширить функциональность, мы просто создаём новую реализацию и подставляем её — менять код самого класса не нужно.
  • Если DI нет, то мы почти всегда приходим к «лестнице if-else» или match с перечислением всех вариантов, и тогда любое новое поведение = модификация старого кода → нарушение OCP.

✅ Нужен новый способ оплаты? Просто создаём новый тип, реализующий Payment. И OrderService остаётся неизменным.

trait Payment {
    fn pay(&self, amount: f64);
}

struct OrderService<T: Payment> {
    payment: T,
}

impl<T: Payment> OrderService<T> {
    fn checkout(&self, amount: f64) {
        self.payment.pay(amount);
    }
}

В микросервисах — через расширяемые API. API проектируется так, чтобы его можно было расширять без поломки клиентов.

Например, у вас есть REST API:

GET /orders/{id}

Вы добавляете новый эндпоин:

GET /orders/{id}/status

или новое поле в JSON:

{
  "id": 1,
  "status": "shipped",
  "tracking": "AB123456789"
}

✅ Клиенты, которые не знают про tracking, продолжают работать. API «закрыт» для поломок, но «открыт» для расширений.

Представьте себе классическое наследование ООП: вы создали родительский класс, а затем позже расширили его дочерним классом с дополнительной функциональностью. Затем по какой-то причине вы решили изменить внутреннюю структуру родительского класса (например, добавить новое поле или удалить какой-то метод), которая также доступна или напрямую влияет на производный класс. Делая это, вы нарушаете этот принцип, потому что теперь вам не только нужно изменить родительский класс, но и адаптировать дочерний класс для новых изменений. Это происходит из-за того, что сокрытие информации не применяется должным образом.

Клиент должен зависеть от абстракций (интерфейсов, абстрактных классов, публичных контрактов), а не от деталей реализации. Тогда код клиента «закрыт» от изменений, но «открыт» к новым возможностям, которые реализуют ту же абстракцию.



Принцип замены Liskov (LSP - Liskov Substitution Principle)

«Объекты в программе должны быть заменены экземплярами их подтипов без изменения корректности этой программы»

Принцип подстановки Лисков (LSP)

Формальное определение:

«Если S — подтип T, то объекты типа T могут быть заменены объектами типа S без изменения корректности программы».

Суть принципа:
Подтипы должны быть полностью взаимозаменяемы с базовыми типами. Это означает, что код, работающий с типом T, должен продолжать работать корректно, если вместо T подставить его подтип S.

Ключевые концепции

  1. Подтип vs Подкласс

    • Подтип — это тип, совместимый с другим типом по поведению (например, u32 — подтип i64 в некоторых контекстах).
    • Подкласс — это структурное наследование (актуально для ООП, но не для LSP в общем случае).
  2. Контракт типа
    Подтип обязан соблюдать контракт базового типа:

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

Правила LSP для типов

  1. Совместимость сигнатур

    • Методы подтипа должны принимать те же или более широкие входные типы.
    • Возвращаемые значения должны быть те же или более узкие.
  2. Исключения

    • Подтип не должен вводить новые исключения, которые не описаны у базового типа.
  3. Инварианты

    • Подтип не должен нарушать условия, которые гарантирует базовый тип.
    • Пример: если базовый тип требует value > 0, подтип не может допускать value = 0.
  4. История (опционально)

    • Подтип не должен изменять состояние системы неожиданным для базового типа образом.

Пример нарушения LSP

Допустим, у нас есть:

  • Базовый тип Транспорт с методом изменить_скорость(delta: i32).
  • Подтип ГоночныйТранспорт, который ускоряется в 2 раза быстрее: изменить_скорость(delta: i32 * 2).

Проблема:
Код, ожидающий Транспорт, может сломаться, если получит ГоночныйТранспорт, потому что:

  • Ожидается линейное изменение скорости, но подтип даёт нелинейное.
  • Это нарушает контракт базового типа.

Как исправить:
Сделать ГоночныйТранспорт отдельным типом без наследования или переопределить контракт явно.

LSP в языках без ООП (например, Rust)

В Rust подтипизация реализуется через трейты и типы-подтипы (например, &dyn Trait).

Пример корректного LSP:

trait Transport {
    fn change_speed(&mut self, delta: i32);
}

struct Car;
impl Transport for Car {
    fn change_speed(&mut self, delta: i32) { ... } // Линейное изменение
}

struct RacingCar;
impl Transport for RacingCar {
    fn change_speed(&mut self, delta: i32) { ... } // Также линейное (контракт сохранён)
}

// Функция работает с любым `impl Transport`
fn test_drive(transport: &mut impl Transport) {
    transport.change_speed(10);
}

let mut car = Car;
let mut racer = RacingCar;
test_drive(&mut car);   // OK
test_drive(&mut racer); // OK, если контракт не нарушен

Нарушение LSP в Rust:
Если RacingCar::change_speed будет умножать delta на 2, это нарушит ожидания test_drive.

Итог

  • LSP — про поведенческую совместимость типов, а не про наследование.
  • Подтип должен быть прозрачной заменой базового типа.
  • Нарушение LSP приводит к неожиданным ошибкам при подстановке типов.
  • В Rust принцип применяется через трейты и полиморфизм (impl Trait, dyn Trait).

Главное правило:

«Код, работающий с базовым типом, не должен знать, что ему подменили подтип».


Принцип разделения интерфейсов (ISP - Interface Segregation Principle)

«Интерфейс отвечающий за одну область задач лучше, чем один интерфейс общего назначения».

Любой код не должен зависеть от методов, которые ему не нужны. Если клиент не использует какое-то поведение объекта, почему он должен быть вынужден зависеть от него? Аналогично, если клиент не использует какие-то методы, почему реализатор должен быть вынужден предоставлять эту функциональность?

Разбейте «толстые» интерфейсы на более конкретные. Если вы измените конкретный интерфейс, эти изменения не повлияют на не связанных с ним клиентов.



Принцип инверсии зависимостей (DIP - Dependency Inversion Principle)
  1. Нужно «зависеть от абстракций, а не от конкретики».

  2. Реализация «подключается» к абстракции, а не наоборот.

Вместо того чтобы высокоуровневый модуль (логика приложения) напрямую использовал низкоуровневый модуль (конкретная реализация), оба работают через абстракцию (интерфейс, протокол, контракт).

Инверсия зависимости состоит из двух основных утверждений:

  1. Модули высокого уровня не должны зависеть от модулей низкого уровня. Оба должны зависеть от абстракций
  2. Абстракции не должны зависеть от деталей. Детали должны зависеть от абстракций.

В Rust DIP чаще реализуют через:

  • trait как абстракцию;
  • generic-параметры с трейт-ограничениями (<T: Logger>);
  • или динамическое распределение (Box<dyn Logger>), если нужна замена в runtime.

❌ UserService жёстко зависит от конкретного FileLogger. Если мы захотим писать в БД или в консоль, придётся менять код UserService → нарушаем OCP

struct FileLogger;

impl FileLogger {
    fn log(&self, message: &str) {
        println!("Log to file: {message}");
    }
}

struct UserService {
    logger: FileLogger,
}

impl UserService {
    fn new() -> Self {
        Self { logger: FileLogger }
    }

    fn register_user(&self, name: &str) {
        self.logger.log(&format!("User registered: {name}"));
    }
}
fn main() {
    let service = UserService::new();
    service.register_user("Alice");
}

✅ Абстракция (trait Logger) — UserService зависит только от интерфейса. Реализации (FileLogger, ConsoleLogger) можно менять и добавлять без изменения UserService → OCP соблюдён. Инверсия зависимостей — теперь не UserService создаёт FileLogger, а внешний код внедряет зависимость.

// Абстракция (контракт)
trait Logger {
    fn log(&self, message: &str);
}
// Одна реализация
struct FileLogger;
impl Logger for FileLogger {
    fn log(&self, message: &str) {
        println!("Log to file: {message}");
    }
}
// Другая реализация
struct ConsoleLogger;
impl Logger for ConsoleLogger {
    fn log(&self, message: &str) {
        println!("Console log: {message}");
    }
}
// Высокоуровневый модуль зависит от абстракции
struct UserService<'a, L: Logger> {
    logger: &'a L,
}
impl<'a, L: Logger> UserService<'a, L> {
    fn new(logger: &'a L) -> Self {
        Self { logger }
    }

    fn register_user(&self, name: &str) {
        self.logger.log(&format!("User registered: {name}"));
    }
}
fn main() {
    let file_logger = FileLogger;
    let console_logger = ConsoleLogger;

    let service1 = UserService::new(&file_logger);
    service1.register_user("Alice");

    let service2 = UserService::new(&console_logger);
    service2.register_user("Bob");
}

Основные моменты критики SOLID

SOLID полезен как чек-лист и набор рекомендаций, но не как строгий закон.

Проблема не в самих принципах, а в буквализме: следование им без учёта контекста и масштаба проекта. Хорошие разработчики используют SOLID избирательно, совмещая его с KISS, YAGNI и здравым смыслом.

  1. Избыточность и усложнение. Следование принципам «в чистом виде» часто ведёт к созданию слишком большого количества абстракций, интерфейсов, классов и прослоек. Это нарушает KISS и иногда YAGNI.
  2. Не всегда применимо в функциональном или процедурном стиле. В Rust DIP часто реализуется просто через generics и traits, без выдумывания слоёв DI-контейнеров.
  3. S - Single Responsibility Principle слишком расплывчат. «Одна ответственность» можно трактовать по-разному — модуль может менять код по сотне причин, но все они будут «логически одной зоной ответственности» (или наоборот — одну функцию можно раздробить на пять классов).
  4. O - Open/Closed Principle сложно соблюдать в реальности. Часто проще поменять существующий код, чем плодить дополнительные расширения через наследование/композицию. В маленьких и средних проектах «открытость/закрытость» может создавать избыточные абстракции ради гипотетической возможности расширения.
  5. L — Liskov Substitution Principle не всегда полезен. В реальном коде часто нарушается LSP ради оптимизации или особых случаев.
  6. I — Interface Segregation Principle иногда вреден. Разделение интерфейсов может привести к слишком большому количеству мелких интерфейсов, что усложняет понимание кода.
  7. D — Dependency Inversion Principle часто приводит к overengineering. В Rust тот же DIP почти всегда делается проще (через параметры и generic-constraints), а «контейнеры» нужны редко.

KISS

KISS (Keep It Simple, Stupid) Большинство систем работают надёжнее и обслуживаются проще, если они остаются простыми, а не усложняются. Простота должна быть ключевой целью проектирования, а излишняя сложность — исключением, а не нормой.

...

Ключевые идеи:

  1. Простота кода — превыше всего, потому что простой код легче понять, тестировать, сопровождать и изменять.
  2. Использовать паттерн проектирования только при наличии проблемы, которую он реально решает. Применение паттернов "на всякий случай" нарушает KISS, добавляя лишнюю сложность.
  3. Не использовать паттерн проектирования там, где он нужен, — тоже нарушение KISS, потому что это усложняет код за счёт ручных решений, уже решённых известным подходом.
  4. Простота достигается не минимизацией кода любой ценой, а минимизацией сложности — явной и скрытой.

Исторический контекст:

Фраза KISS («будь проще, глупый») была введена авиаинженером Келли Джонсоном. Его команда должна была спроектировать реактивный самолёт так, чтобы его можно было обслуживать обычным механиком в полевых условиях с ограниченным набором инструментов. Главная идея: проектируйте системы так, чтобы они были понятны, ремонтопригодны и работоспособны при минимуме необходимых средств.

Всвязи с тем, что представления разных людей о таком понятии как «простота» могут различаться, приобрели широкое распространение следующая заблуждения относительно KISS-a:

  • Заблуждение 1. Если считать, что простой код – это такой код, который проще всего написать, то можно истолковать, что принцип KISS призывает писать первое что взбредёт в голову, вообще не задумываясь о проектировании.
  • Заблуждение 2. Если считать, что простой код – это такой код, для написания которого требуется как можно меньше знаний, то можно истолковать, что принцип KISS призывает не использовать паттерны проектирования.

Принцип наименьшего удивления (POLA - Principle of least astonishment). Компонент системы должен вести себя так, как ожидает большинство пользователей. Его поведение не должно вызывать удивления или удивления у пользователей.

KISS — принцип проектирования, содержащий все остальные принципы проектирования


DRY

"Не повторяйте себя" (Don’t Repeat Yourself).

...

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

В книге «Программист-прагматик» мы можем увидеть такое определение DRY: «Каждая часть знаний должна иметь единственное, однозначное и авторитетное представление в системе»

Суть:

  • Избегайте дублирования кода, логики и знаний.
  • Предпочитайте модульный и переиспользуемый код.
  • Все изменения должны вноситься в одном месте.

Почему это важно:

  1. Легче поддерживать — меняешь в одном месте, и всё обновляется.
  2. Меньше багов — нет риска, что в одном месте обновили, а в другом забыли.
  3. Код компактнее и чище — легче читать, понимать и тестировать.

Опасности дублирования:

  • Разные версии "одного и того же" кода со временем расходятся.
  • Исправление ошибки в одном месте не исправляет её в других.
  • Рост сложности и размера кода без реальной пользы.

YAGNI

"Тебе это не понадобится" (You Aren’t Gonna Need It)

...

Это означает, что вам не следует реализовывать функциональность только потому, что вы думаете, что она вам когда-нибудь понадобится, а реализовывать ее только тогда, когда она вам действительно понадобится. Поступая так, вы избежите траты времени на реализации, которые даже не были необходимы и, возможно, никогда не будут использоваться.

Не реализовывай функциональность "на всякий случай". Делай только то, что нужно прямо сейчас, на основе подтверждённых требований.

Суть:

  • Не трать время на код, который возможно пригодится.
  • Избегай преждевременной оптимизации и расширения.
  • Реализуй только то, что необходимо для текущих задач.

Почему это важно:

  1. Экономия времени — ты не пишешь лишний код.
  2. Меньше багов — нет ненужных функций, которые могут сломаться.
  3. Меньше сложности — легче поддерживать и понимать систему.
  4. Сочетается с KISS — нет искусственного усложнения.
  5. Сочетается с DRY — меньше шансов на дублирование "полуготовых" решений.

Design principles

dry-kiss-yagni-principles


GRASP

Общие принципы распределения ответственности (GRASP) — это набор из девяти принципов, используемых в объектно-ориентированном проектировании, представленных Крейгом Ларманом в его книге «Применение UML и шаблонов».

...
  1. Information Expert (Информационный эксперт) Шаблон определяет базовый принцип распределения обязанностей:

"Информационный эксперт" — это не всегда единственный владелец данных, а тот, кто имеет их в наибольшем объёме и может выполнить обязанность с минимальными зависимостями.

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

Этот шаблон — самый очевидный и важный из девяти.

Это способствует уменьшению зависимости между объектами и повышает инкапсуляцию.

Если его не учесть — получится спагетти-код, в котором трудно разобраться.

Локализация же обязанностей, проводимая согласно шаблону:

Повышает:

  • Инкапсуляцию;
  • Простоту восприятия;
  • Готовность компонентов к повторному использованию;

Снижает:

  • Степень зацеплений.
  1. Creator (Создатель)

Шаблон определяет, какой объект должен быть ответственным за создание экземпляров другого объекта. Создатель не только "создаёт", но и инициализирует объект в корректном состоянии, чтобы снизить риски неправильной конфигурации.

Обычно это объект, который:

  • Использует создаваемый объект,
  • Обладает агрегированными данными для создаваемого объекта,
  • Является родительским для создаваемого объекта,
  • Хранит или записывает экземпляры создаваемого объекта.

Альтернатива — шаблон «Фабрика» (создание объектов концентрируется в отдельном классе).

  1. Controller (Контроллер)

Этот шаблон определяет объект, который принимает и координирует выполнение операций.

Контроллеры обычно представляют собой объекты, управляющие жизненным циклом других объектов. Контроллер в GRASP — не обязательно MVC-контроллер, а любой объект, обрабатывающий сценарии использования.

Отвечает за операции, запросы на которые приходят от пользователя, и может выполнять сценарии одного или нескольких вариантов использования (например, создание и удаление).

Не выполняет работу самостоятельно, а делегирует компетентным исполнителям;

Может представлять собой:

  • Систему в целом;
  • Подсистему;
  • Корневой объект;
  • Устройство.
  1. Low Coupling (Низкая связанность)

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

«Степень зацепления» (сопряжения[2]) — мера неотрывности элемента от других элементов (либо мера данных, имеющихся у него о них).

«Слабое» зацепление — распределение обязанностей и данных, обеспечивающее взаимную независимость классов. Класс со «слабым» зацеплением:

  • Не зависит от внешних изменений;
  • Прост для повторного использования.
  1. High Cohesion (Высокая когезия)

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

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

Т.е. не реализовывать большой функционал в один класс,а вместо этого разнести на несколько классов.

Предметные области следует разделять по классам.

Связность класса — мера подобия предметных областей его методов:

«Высокая» степень — сфокусированные подсистемы (предметная область определена, управляема и понятна);

«Низкая» степень — абстрактные подсистемы.

Затруднены:

  • Восприятие;
  • Повторное использование;
  • Поддержка;
  • Устойчивость к внешним изменениям.

Низкая связанность и Высокая когезия, всегда рассматриваются вместе — иногда уменьшение связанности снижает когезию, и наоборот, поэтому баланс важен.

  1. Polymorphism (Полиморфизм)

Речь идёт об интерфейсном полиморфизме (через абстракции), а не только о наследовании. Использование полиморфизма позволяет заменить условные операторы (например, if или switch) вызовами методов, которые могут быть реализованы различными способами в разных классах. Это улучшает расширяемость и изменяемость кода.

Устройство и поведение системы:

  • Определяется данными;
  • Задано полиморфными операциями её интерфейса.

Пример: Адаптация коммерческой системы к многообразию систем учёта налогов может быть обеспечена через внешний интерфейс объектов-адаптеров (смотрите также: Шаблон «Адаптеры»).

  1. Pure Fabrication (Чистая фабрикация)

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

Не относится к предметной области, но:

  • Уменьшает зацепление;
  • Повышает связность;
  • Упрощает повторное использование.

«Pure Fabrication» отражает концепцию сервисов в модели проблемно-ориентированного проектирования.

Пример задачи: Не используя средства класса «А», внести его объекты в базу данных.

Решение: Создать класс «Б» для записи объектов класса «А» (смотрите также: «Data Access Object»).

  1. Indirection (Косвенность)

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

Например, использование паттерна "Посредник" (Mediator) для управления коммуникацией между модулями.

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

Пример: В архитектуре Model-View-Controller, контроллер (англ. controller) ослабляет зацепление данных (англ. model) за их представление (англ. view).

  1. Protected Variations (Устойчивость к изменениям)

Принцип, при котором система разрабатывается таким образом, чтобы защитить части системы от влияния вариаций в других частях. Мы явно выделяем точки потенциальных изменений и защищаем остальную систему с помощью стабильных интерфейсов или контрактов. Это может быть достигнуто с помощью интерфейсов, абстракций и других методов. Шаблон защищает элементы от изменения другими элементами (объектами или подсистемами) с помощью вынесения взаимодействия в фиксированный интерфейс, через который (и только через который) возможно взаимодействие между элементами. Поведение может варьироваться лишь через создание другой реализации интерфейса.


LoD

The Law of Demeter (LoD) Закон Деметры

Принцип Деметры — это принцип проектирования программного обеспечения, направленный на минимизацию связности между различными компонентами системы. Он также известен как "принцип наименьшего знания". Объект должен взаимодействовать только с ближайшими соседями (не ходить через цепочки вызовов).

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

Простыми словами: Каждый объект должен знать о структуре других объектов как можно меньше. То есть объект не должен обращаться напрямую к внутренностям других объектов и передавать управление дальше по цепочке.

...

Говоря упрощённо, каждый программный модуль:

  • должен обладать ограниченным знанием о других модулях: знать о модулях, которые имеют «непосредственное» отношение к этому модулю.
  • должен взаимодействовать только с известными ему модулями «друзьями», не взаимодействовать с незнакомцами. обращаться только к непосредственным «друзьям».

LoD напрямую помогает достигать "Low Coupling" (из GRASP) и облегчает рефакторинг.

Аналогия из жизни: Если Вы хотите, чтобы собака побежала, глупо командовать её лапами, лучше отдать команду собаке, а она уже разберётся со своими лапами сама.

Основной идеей является то, что объект должен иметь как можно меньше представления о структуре и свойствах чего угодно (включая собственные подкомпоненты).

Общее описание правила: Объект A не должен иметь возможность получить непосредственный доступ к объекту C, если у объекта A есть доступ к объекту B и у объекта B есть доступ к объекту C.

Более формально, Закон Деметры для функций требует, что бы метод М у объекта О мог вызывать методы только следующих типов объектов:

  • собственно самого О
  • параметров М
  • других объектов, созданных в рамках М
  • прямых компонентных объектов О
  • глобальных переменных, доступных О, в пределах М

Практически, объект-клиент должен избегать вызовов методов объектов, внутренних членов, возвращенных методом объекта-сервиса.

В общем случае можно сказать, что LoD не работает, когда к одному объекту применено знание особенностей устройства через другой обьект, например, object.friend.stranger вместо object.friend или такое нарушение принципа String cityName = person.getAddress().getCity().getName();

В Rust меньше риск нарушить LoD через прямой доступ, если поля pub не выставлены. Инкапсуляция через pub(crate) и приватные поля помогает соблюдать LoD естественным образом.

❌ Нарушение LoD

struct User {
    pub account: Account,
}

struct Account {
    pub balance: u64,
}

// ❌ нарушение LoD (прямой доступ к вложенному полю)
fn print_user_balance(user: &User) {
    println!("Balance: {}", user.account.balance); 
}

✅ Соблюдение LoD

impl User {
    pub fn get_balance(&self) -> u64 {
        self.account.get_balance()  
    }
}

impl Account {
    pub fn get_balance(&self) -> u64 {
        self.balance
    }
}

fn print_user_balance(user: &User) {
    println!("Balance: {}", user.get_balance()); // ✅
}

SoC

SoC (Separation of Concerns) — это принцип разделения обязанностей или ответственности в программной инженерии, который предполагает, что разные части программы должны решать строго определённые задачи и быть независимыми друг от друга.

Суть принципа: Каждый компонент системы должен отвечать только за одну "зону ответственности" или "аспект". Эти зоны ответственности должны быть максимально разделены, чтобы изменения в одной зоне не затрагивали другие.

SoC тесно переплетается с SRP (Single Responsibility Principle из SOLID). Разница:

  • SRP — применим к отдельному классу/модулю: «один класс — одна причина для изменения»
  • SoC — более общий архитектурный принцип: «разные аспекты системы — разные зоны ответственности».
...

Основные идеи SoC:

  1. Каждый модуль или компонент должен иметь свою четкую цель.
  2. Независимость компонентов: Компоненты с разными зонами ответственности должны быть изолированы и не зависеть друг от друга.
  3. Легкость изменений: Изменения в одной зоне не должны приводить к изменению других частей системы, что повышает гибкость и упрощает сопровождение.
  4. Повторное использование кода: Компоненты, отвечающие за одну задачу, проще повторно использовать в других системах или контекстах.

Примеры применения SoC:

  1. Архитектурные паттерны:

    • MVC (Model-View-Controller): Яркий пример применения SoC. Модель отвечает за логику данных, представление — за отображение данных, контроллер — за управление потоком данных между моделью и представлением.
    • Микросервисы: Каждая служба отвечает за конкретную часть системы, что позволяет разделить различные зоны ответственности (например, учет пользователей, обработка платежей и т.д.).
  2. Модули и библиотеки: В больших проектах различная функциональность может быть разделена на независимые модули или библиотеки. Например, один модуль отвечает за работу с базой данных, другой — за взаимодействие с внешними API.

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

Преимущества SoC:

  1. Поддерживаемость: Поскольку каждая часть системы отвечает только за одну задачу, изменения и ошибки проще локализовать.
  2. Гибкость: Разделенные компоненты можно изменять, заменять или улучшать независимо друг от друга.
  3. Тестируемость: Компоненты проще тестировать по отдельности, так как они изолированы от других частей системы.
  4. Повторное использование: Компоненты с четкими зонами ответственности легче использовать повторно в других проектах.

Пример: В приложении для онлайн-покупок можно выделить несколько зон ответственности:

  • Модуль для управления пользователями (регистрация, аутентификация).
  • Модуль для управления товарами (каталог, поиск).
  • Модуль для обработки платежей.
  • Модуль для обработки заказов.

Каждая из этих частей имеет свою зону ответственности и может развиваться независимо от других.

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


Single Level of Abstraction (SLA)

Каждая функция или метод должна работать только на одном уровне абстракции — либо высокоуровневом (бизнес-логика, сценарии), либо низкоуровневом (конкретные операции с данными, технические детали), но не смешивать их.

Почему это важно:

  • Код становится проще читать, потому что мозгу не приходится переключаться между «что» (высокоуровневое) и «как» (низкоуровневое) в одном методе.
  • Легче тестировать — можно отдельно тестировать высокоуровневую логику и низкоуровневые детали.
  • Упрощается рефакторинг — изменения на одном уровне не задевают другой.
...

Нарушение SLA

fn process_data(data: &str) -> Result<(), String> {
    // Низкоуровневая проверка
    if data.is_empty() {
        return Err("Empty data".to_string());
    }

    // Высокоуровневая логика
    let parsed = parse_data(data)?;

    // Опять низкоуровневая работа
    save_to_db(&parsed).map_err(|e| e.to_string())
}

Соблюдение SLA

fn process_data(data: &str) -> Result<(), String> {
    validate_data(data)?;
    let parsed = parse_data(data)?;
    store_data(parsed)
}

// Высокоуровневые шаги
fn validate_data(data: &str) -> Result<(), String> { ... }
fn parse_data(data: &str) -> Result<ParsedData, String> { ... }
fn store_data(data: ParsedData) -> Result<(), String> { ... }

Command-Query Separation (CQS)

CQS — это принцип проектирования методов, который можно и нужно использовать практически везде, предложенный Бертраном Мейером, который разделяет методы на две категории:

  • Команды (Commands) — изменяют состояние, но не возвращают данные
  • Запросы (Queries) — возвращают данные, но не изменяют состояние

Метод должен быть либо командой, либо запросом, но не обоими одновременно.

...

Преимущества CQS

  • Предсказуемость кода (ясно когда изменяется состояние)
  • Безопасность в многопоточности (запросы можно вызывать из нескольких потоков без блокировок)
  • Упрощение тестирования

❌ Нарушение CQS

// ПЛОХО: метод и изменяет состояние, и возвращает значение
struct BankAccount {
    balance: f64,
}

impl BankAccount {
    fn withdraw(&mut self, amount: f64) -> f64 {
        self.balance -= amount;
        self.balance // Нарушение: и команда, и запрос
    }
}

✅ Соблюдение CQS

// ХОРОШО: разделение на команду и запрос
struct BankAccount {
    balance: f64,
}

impl BankAccount {
    // КОМАНДА: изменяет состояние, ничего не возвращает
    fn withdraw(&mut self, amount: f64) {
        self.balance -= amount;
    }
    
    // ЗАПРОС: возвращает данные, не изменяет состояние
    fn get_balance(&self) -> f64 {
        self.balance
    }
}

Исключения из правила:

  • Создание объектов (фабричные методы)
impl User {
    // Исключение: создание объекта обычно возвращает его
    fn new(name: &str) -> Self {
        Self {
            id: generate_id(),
            name: name.to_string(),
        }
    }
}
    1. Операции с очевидными побочными эффектами
struct Logger;
impl Logger {
    // Запись в лог - и команда, и может возвращать статус
    fn log(&mut self, message: &str) -> Result<(), IoError> {
        println!("{}", message);
        Ok(())
    }
}