Svartsinn1
Programming. Почему Solid никто не понимает?

Самиздат: [Регистрация] [Найти] [Рейтинги] [Обсуждения] [Новинки] [Обзоры] [Помощь|Техвопросы]
Links
Кожевенное мастерство: сумки, ремни своими руками
 Ваша оценка:

Собеседование на позицию "разработчик, middle". Кандидат спокойно, почти нараспев, перечисляет: "SOLID. Принцип единственной ответственности, принцип открытости-закрытости, принцип подстановки Барбары Лисков, сегрегации интерфейсов и инверсии зависимостей". Ведущий кивает и задаёт единственный вопрос: "Хорошо. Зачем нужен третий?". Пауза. Кандидат мнётся, вспоминает формулировку про базовые классы, сбивается и заканчивает фразой "ну... чтобы наследование работало корректно". Формулировка верная на сто процентов. Смысла в ней - ноль.

Ситуация не выдумана, она повторяется из года в год в тысячах компаний. SOLID давно стал валютой рынка: акроним встречается в каждом третьем описании вакансии, в резюме, в темах youtube-курсов по "чистой архитектуре". Опросы и форумы вроде Stack Overflow и Reddit подтверждают то же, что видит любой тимлид: почти каждый может процитировать принципы, почти никто не может объяснить, какую боль они лечат. Знание выучено, понимание не появилось.

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

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

Откуда взялись эти пять букв

В 2000 году Роберт Мартин, известный в сообществе как "дядя Боб", опубликовал статью "Design Principles and Design Patterns". Её темой было гниение кода - явление, при котором здоровый проект за пару лет превращается в болото, где каждую правку приходится сопровождать молитвой. Мартин утверждал, что гниение не судьба, а следствие плохой архитектуры, и предложил набор принципов, которые должны её останавливать. Четыре буквы из пяти придумал он сам, пятая досталась в наследство от Барбары Лисков, которая сформулировала свою идею ещё в 1987 году.

Само слово SOLID появилось позже, примерно в 2004 году, и придумал его Майкл Фэзерс - консультант и автор книги "Working Effectively with Legacy Code". Акроним оказался маркетинговым шедевром: пять запоминающихся букв. Именно эта упаковка обеспечила идеям массовое распространение. Но у удобной упаковки есть цена: чем короче формула, тем легче её выучить и тем труднее понять.

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

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

S: одна причина, а не одно действие

Принцип единственной ответственности (Single Responsibility Principle) - рекордсмен по неправильным толкованиям. Его обычно пересказывают так: "класс должен делать только одну вещь" или "функция должна выполнять только одну задачу". Звучит красиво и почти полностью неверно. Если следовать этой версии буквально, придётся дробить код до абсурда: метод отправки письма разбивать на "сформировать текст", "подключить сокет", "прочитать ответ сервера", - пока проект не превратится в десять тысяч файлов по три строки.

Настоящий смысл тоньше. "Ответственность" в формулировке Мартина означает "причину для изменения". Класс должен иметь одну причину меняться, иначе говоря - одного хозяина. В статье 2014 года Мартин уточнил это до понятия актёра: код должен замыкаться на одну группу людей, которая в него приходит с требованиями. Упрощённо: если класс меняется, когда бухгалтерия просит одну вещь, юристы - вторую, а вёрстка - третью, у класса три хозяина и три причины деградировать.

Классический пример - класс, который делает отчёт целиком:

class Report:
    def calculate(self, raw_data):
        # расчёт итоговых сумм
        ...
    def render_html(self):
        # превращение в красивую HTML-страницу
        ...
    def save_to_database(self):
        # запись в базу для истории
        ...

На первый взгляд класс выглядит прилично: три аккуратных метода, ничего лишнего. Но у него три хозяина. Финансист попросит изменить формулу расчёта, дизайнер - разметку страницы, администратор базы - схему хранения. Каждая просьба означает правку одного файла, риск задеть чужую логику и конфликт в системе версий. Методы небольшие, но связаны не смыслом, а случайностью совместного проживания.

Реструктурирование здесь - не дробление методов, а разделение хозяев:

class ReportCalculator:
    def calculate(self, raw_data): ...

class ReportRenderer:
    def render(self, report): ...

class ReportStorage:
    def save(self, report): ...

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

Мартин, кстати, публично жалел, что выбрал слово "ответственность" - оно и правда вводит в заблуждение. Если держать в голове не "делает одну вещь", а "меняется по одной причине", принцип перестаёт требовать религии и начинает помогать мышлению. Проверка проста: назовите людей, которые могут потребовать изменений в классе, если их больше одной группы - есть повод разделить.

Код, закрытый на ремонт

Принцип открытости-закрытости (Open-Closed Principle) в стандартном пересказе звучит так: "сущности должны быть открыты для расширения, но закрыты для модификации". Формулировку придумал Бертран Мейер в 1988 году, Мартин её переработал. Из неё делают два главных вывода, оба вредные: "нельзя править уже написанный код" и "все операторы switch надо заменить полиморфизмом". Первый вывод парализует разработку, второй рождает иерархии классов там, где хватало одного словаря.

Настоящая идея - стратегическое закрытие. Невозможно написать класс так, чтобы он выдержал любые изменения будущего, - предвидеть всё нельзя. Мартин прямо об этом писал: нужно закрыть класс от конкретных изменений, которые предвидишь и считаешь вероятными. Иными словами, выбрать оси, вдоль которых система будет меняться, и построить там шов - место, куда изменения встраиваются без перековки старого кода. Позже сам Мартин смягчил формулировку до разумной: "в идеале вы добавляете новый код и почти не трогаете старый".

Рассмотрим начисление скидок:

def discount(user):
    if user.type == "vip":
        return 0.2
    if user.type == "employee":
        return 0.3
    if user.type == "partner":
        return 0.1
    return 0.0

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

DISCOUNTS = {}

def register(name):
    def wrap(fn):
        DISCOUNTS[name] = fn
        return fn
    return wrap

@register("vip")
def vip_discount(user):
    return 0.2

def discount(user):
    fn = DISCOUNTS.get(user.type)
    return fn(user) if fn else 0.0

Новый вид клиента - это теперь новая функция с декоратором, а не правка чужой логики. Расширение добавляется отдельно от существующего кода.

Но вот что важно понимать: второй вариант сложнее первого. Он добавляет уровень косвенности, и за неё надо платить только тогда, когда расширения действительно предвидятся. Если маркетинг за два года ни разу не менял виды клиентов, "закрытый" код был обычной преждевременной абстракцией. Принцип не требует строить швы везде - он предлагает решать, где их строить. Именно этого решения, требующего понимания бизнеса, невозможно выучить списком правил.

Квадрат, который сломал прямоугольник

Принцип подстановки Лисков (Liskov Substitution Principle) - старейший в пятёрке. Барбара Лисков сформулировала его в 1987 году, за тринадцать лет до статьи Мартина: функции, использующие ссылки на базовый тип, должны уметь подставлять объекты производного типа, не узнавая об этом. В пересказах принцип вырождается в два пустых утверждения: "наследование - хорошо, используйте наследование" и "подкласс должен реализовать методы родителя". На деле это единственный из пяти принципов, который говорит не о структуре классов, а о контракте - и потому его нарушения самые дорогие.

Смысл проще показать, чем определить. Взгляд со стороны: любой код, работающий с объектом базового типа, должен работать с производным типом без сюрпризов. Производный класс не обязан уметь больше, он обязан уметь всё то же и без песен. Нарушение выглядит так:

class Rectangle:
    def __init__(self):
        self._width = 0
        self._height = 0

    def set_width(self, w):
        self._width = w

    def set_height(self, h):
        self._height = h

    def area(self):
        return self._width * self._height


class Square(Rectangle):
    def set_width(self, w):
        self._width = w
        self._height = w   # квадрат держит стороны равными

    def set_height(self, h):
        self._width = h
        self._height = h

В бытовом смысле квадрат и правда частный случай прямоугольника. Но посмотрите на код, который писался для прямоугольников:

def stretch(rect):
    rect.set_width(4)
    rect.set_height(5)
    return rect.area()   # ждём 20

stretch(Square())        # получаем 25

Код, безупречный для базового класса, врёт с производным. Клиент построил ожидания на контракте базового типа: установка ширины не трогает высоту. Квадрат контракт нарушил, и ошибка проявится где-то в далёком модуле, в чужой строчке, где никто не подозревает квадратов. Вот что запрещает принцип Лисков: подтип не имеет права разочаровывать ожидания клиентов родителя, сколь бы логичным он ни выглядел в диаграмме классов.

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

Робот, которого заставили есть

Принцип сегрегации интерфейсов (Interface Segregation Principle) - наименее известная буква, потому что на неё почти не бывает красивых собеседовательных вопросов. Смысл простой и мирный: клиент не должен зависеть от методов, которые ему не нужны. Толстый интерфейс, навязывающий всем своим пользователям весь свой рацион, - это скрытая связность: изменение ненужного метода заставляет перестраивать тех, кому он безразличен.

Тех, кто не понимает, что здесь ломается, спасает старая притча про работника:

class Worker:
    def work(self): ...
    def eat(self): ...
    def sleep(self): ...

class Human(Worker):
    def work(self): "работает"
    def eat(self): "обедает"
    def sleep(self): "спит"

class Robot(Worker):
    def work(self): "работает"
    def eat(self): raise NotImplementedError
    def sleep(self): raise NotImplementedError

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

Лечение - разделить интерфейс по ролям, которые реально нужны потребителям:

class Workable:
    def work(self): ...

class Eatable:
    def eat(self): ...

class Human(Workable, Eatable):
    ...

class Robot(Workable):
    ...

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

Инъекция - это не инверсия

Принцип инверсии зависимостей (Dependency Inversion Principle) страдает от фамильного сходства терминов. Рядом живут "dependency injection" - приём, когда зависимости передаются в класс извне через конструктор, - и "inversion of control" - общая идея о том, что каркас сам вызывает код приложения. Все три слова про инверсию и инъекцию часто склеивают в одно и показывают картинку с интерфейсом: заведи рядом с каждым классом интерфейс, реализуй его классом-близнецом с суффиксом Impl, и будет счастье. Именно так SOLID начинает приносить вред.

Настоящий принцип про направление зависимостей исходного кода. Внутри любой программы есть высокоуровневая политика - смысл, ради которого программа существует, заказы проводятся, платежи списываются, - и низкоуровневые детали: конкретная почта, конкретная база, конкретный API. Инверсия говорит: политика не должна зависеть от деталей. Зависимость должна быть направлена от деталей к политике, и скрепкой, которая переворачивает стрелку, служит абстракция, объявленная на стороне политики.

Плохой вариант, где смысл зависит от проводов:

class SmtpMailer:
    def send(self, text): "отправка через SMTP-сервер"

class OrderService:
    def __init__(self):
        self._mailer = SmtpMailer()   # создание детали прямо внутри

    def place(self, order):
        self._mailer.send("order placed")
        ...

Здесь сервис заказов, главная ценность программы, физически склеен с выбором почтового протокола. Сменить SMTP на мессенджер - значит лезть в бизнес-класс. Слова "инверсия" тут пока нет: инъекция сама по себе зависимости не переворачивает.

class Mailer:                 # контракт нужен OrderService, поэтому он
    def send(self, text): ... # и живёт на стороне политики

class OrderService:
    def __init__(self, mailer: Mailer):
        self._mailer = mailer  # деталь приходит снаружи

class SmtpMailer(Mailer):
    def send(self, text): "SMTP, как раньше"

Обратите внимание, чей это интерфейс. Контракт описан языком OrderService - "умею слать уведомления заказов", - а SmtpMailer подстраивается под него. Ещё раз, потому что именно это место понимают хуже всего: если интерфейс скопирован с публичных методов реализации и принадлежит реализации, инверсии не произошло. Получилась косвенность без абстракции: вызывающий код по-прежнему зависит от деталей, только теперь через лишний файл. Это самое массовое повреждение из всех, что происходят с SOLID на практике, и именно его поклонники "чистой архитектуры" воспроизводят структуру папок из книг, не проверяя направление стрелок.

Почему понимания почти нет

Первая причина в способе передачи. SOLID учат лозунгами: два предложения на принцип и пример с животными. Известный разбор misconceptions Раймунда Кремера начинается честным тезисом: любой пересказ принципов без контекста и намерения почти гарантированно будет понят неправильно, и тогда вреда от выученных слов больше, чем от отсутствия знаний, - потому что человек уверен в своей правоте. Чтение двух предложений и примера с фигурками - не обучение, это загрузка пароля для собеседования. Настоящее изучение требует того, что в педагогике называется двойным циклом: не только что, но и зачем, где не работает, какими были альтернативы. Такой текст читать дольше, чем лозунги, поэтому его никто не читает.

Вторая причина - дрейф самих определений. Мартин за двадцать лет переформулировал и SRP, и OCP, поскольку наблюдал неверное применение своих же слов. "Ответственность" сменилась на актёра и причину изменения, "закрыт для модификации" смягчилась до "добавляйте новый код и почти не трогайте старый". Но на каждом собеседовании и в каждом курсе звучит старая, жёсткая формулировка из статьи 2000 года. Представьте учебник анатомии, где половина утверждений опровергнута автором, но цитируется, потому что так учили предыдущее поколение. Пока толпа цитирует те же буквы, которые автор сам уже смягчил, понимание не может сойтись даже теоретически.

Третья причина - сдвиг эпохи. Принципы писались для мира, где правка была дорогой, медленной и опасной. Сегодня IDE делает автоматический рефакторинг за секунды, git сливает изменения тысяч людей, автотесты ловят регрессию, компиляция бесплатна. Часть боли, под которую проектировались швы, просто перестала существовать. Это не отменяет принципы - связность, контракты и управление зависимостями нужны всегда, - но лишает их статуса безусловных догм. Человек, который следует букве SOLID из 2000 года в мире 2026-го, похож на водителя, который объезжает все колдобины по карте двадцатилетней давности: усилия честные, дорога уже другая.

Четвёртая причина - социальная, и она неприятная. SOLID превратился из инструмента в пропускной балл. Его спрашивают на собеседованиях, потому что так делают все, полезность вопроса никого не интересует, и кандидат справедливо учит ответ, а не материал. Знание принципов стало маркером статуса - сигналом "я свой, я читал умные книжки", - и маркер это выполняет отлично. Обратная сторона знакома каждому, кто проводил интервью: человек безупречно воспроизводит формулировки и не может решить задачу, к которой они применимы. Когда знание служит статусу, а не пониманию, оно перестаёт быть знанием и становится паролем в закрытую гильдию. Это общий закон, и софтверная индустрия - лишь одно из его свидетельств.

Пятая причина - принципиальная неформализуемость. Все пять принципов семантические: "один актёр", "вероятная ось изменения", "оправданные ожидания" - это суждения о смысле кода, а не проверяемые свойства строк. Тот же вывод косвенно подтверждает недавняя эмпирика: работа "Are We SOLID Yet?" (ASE 2025, препринт arXiv:2509.03093) испытывала большие языковые модели на распознавание нарушений SOLID в реальном коде и показала, что задача остаётся трудной даже для них. Если правило невозможно проверить механически, его невозможно выучить механически. Альтернативы две: либо рассуждение, либо имитация. Толпа, которой нужны пять букв для резюме, конечно выбирает имитацию.

Шестая причина - экономика мышления. Выученный лозунг когнитивно бесплатен: одна строчка памяти. Понимание требует держать в голове актёров 2000-го года, последствия рефакторинга, примеры из собственного опыта и границы применимости - это дорогостоящее проживание чужой мысли со всеми её издержками. Экономное мышление ищет минимум усилий, а не максимум понимания, и лозунг здесь выигрывает с разгромным счётом.

Что делать, если вы решили понять SOLID всерьёз

Первый практический шаг - читать первоисточники, а не пересказы. Сами статьи Мартина доступны, исходным текстам про SRP и OCP больше двадцати лет, и даже чтение Википедии с переходом по ссылкам на оригинальные PDF даёт больше, чем сотня карточек "SOLID за 5 минут". Хорошая привычка - прочитав формулировку, тут же искать её историю: что автор говорил раньше, что изменил, что признал неточным. Определение без истории применения похоже на фотографию человека без имени - видна форма, непонятен характер.

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

Третий шаг - вносить поправку на эпоху. Спрашивайте себя, какая боль стояла за каждым принципом в 1990-х и существует ли она в вашем проекте. Если CI гоняет автотесты за две минуты, часть страха перед модификацией кода можно считать устаревшей. Если команда из трёх человек пишет сервис на два года, строить швы под расширение полусотни плагинов - просто трата чужого времени. Принцип жив, пока отвечает на реальную боль. Такую боль надо проверять, как проверяют диагноз.

Финальная мысль. Аккуратная аббревиатура сыграла с идеями злую шутку: она упаковала сорок лет размышлений многих людей в пять букв, а индустрия упаковала пять букв в одну строчку резюме. Каждый шаг упаковки терял смысл, и на выходе осталось слово, которое произносят с умным лицом и не могут использовать. Вернуть смысл можно только в обратном порядке - медленно, через понимание конкретной боли конкретного кода. И вот тут есть обнадёживающая деталь: сам автор SOLID двадцать лет переопределял свои формулировки и публично жалел о неудачных словах. Если ему было можно думать и менять мнение, то и вам можно. Начните с вопроса "зачем", и пять незнакомых букв окажутся всего лишь одним знакомым словом.


 Ваша оценка:

Связаться с программистом сайта.

Новые книги авторов СИ, вышедшие из печати:
О.Болдырева "Крадуш. Чужие души" М.Николаев "Вторжение на Землю"

Как попасть в этoт список

Кожевенное мастерство | Сайт "Художники" | Доска об'явлений "Книги"