ООАП. Лекция 2 — Архитектура программных систем. Рефакторинг и устранение «запахов» кода¶
Конспект лектора. Архитектурные идеи и запахи кода от языка не зависят — они одинаково работают в C#, Java, Python и TypeScript. C# взят как язык примеров по знакомости аудитории; врезками показываю, где другой язык решает ту же проблему принципиально интереснее (Kotlin, F#, Go, Rust, TypeScript, Swift). Каталог запахов — по классификации Refactoring.Guru, приёмы — по Фаулеру.
Связка с лекцией 1: там мы учились строить один класс правильно. Сегодня — как из классов собирается система и что делать, когда она уже собрана неправильно.
Блок 1. Проблематика: почему код перестаёт поддаваться¶
Как это выглядит на практике¶
Начинаем проект без продуманной архитектуры. Появляется первый модуль — внутри него связи между компонентами. Появляется второй — связи возникают уже и между модулями. К четвёртому модулю связей столько, что ни один из них нельзя вынуть, не задев остальные.
Ключевое слово — неявные связи. Явную зависимость видно в конструкторе и в списке
using; неявную — только когда что-то сломалось в другом конце приложения.
Три следствия, которые студенты должны запомнить:
- Удаление модуля становится невозможным. На него завязаны другие — прямо или через третьи руки.
- Изменение старого кода становится опасным. Open/Closed говорит «расширяй, не меняй», но бизнес устроен так, что старый код менять придётся. Вопрос лишь в цене.
- Добавление новой фичи замедляется. Не потому, что фича сложная, а потому, что непонятно, куда её класть и что она заденет.
Всё это растёт не линейно. Число потенциальных связей между n модулями — n·(n−1)/2:
5 модулей — 10 связей, 20 модулей — 190. Отсюда ощущение, что «проект стал вязким»
буквально за полгода.
Перевод на язык бизнеса¶
Сложность = время = деньги. Архитектура — это не эстетика и не «красиво разложить по папкам». Это способ удерживать стоимость изменения примерно постоянной на протяжении жизни продукта. Всё остальное в этой лекции — следствие.
Вопрос аудитории для затравки: назовите проект, где вы боялись что-то удалять. Почему боялись? Ответ почти всегда: «непонятно, что от этого зависит». Вот это и есть архитектурный дефект.
Блок 2. Связность и зацепление. Определение архитектуры¶
Два слова, которые всё объясняют¶
- Связность (cohesion) — насколько компоненты внутри модуля направлены на решение одной задачи. Нужна сильная.
- Зацепление (coupling) — насколько модуль зависит от других модулей. Нужно слабое.
Русская терминология плавает: «связанность/зацепленность», «сцепление/связность». На доске пишем английские термины — они однозначны: high cohesion, low coupling.
Матрица четырёх состояний¶
| Сильное зацепление | Слабое зацепление | |
|---|---|---|
| Слабая связность | God Object: модулей нет, всё связано со всем | Модули есть, но внутри свалка; связи между ними случайны |
| Сильная связность | Модули осмысленные, но намертво сцеплены между собой | Цель: модуль решает одну задачу и легко отсоединяется |
Разбор по клеткам:
- God Object на уровне проекта. Не один класс на 3000 строк, а весь проект без выделенных модулей. Всё знает про всё, распутать нельзя.
- Слабая связность + слабое зацепление. Модули формально выделены, но внутри каждого
перемешаны разные задачи. Границы проведены не по смыслу, а «как получилось» — обычно
по техническому признаку:
Utils,Helpers,Common,Services. - Сильная связность + сильное зацепление. Каждый модуль осмысленный, но они держатся
друг за друга. Типично для «правильно разложенного» монолита, где все ссылаются на общую
сборку
Core, куда за год стащили всё подряд. - Идеал — правый нижний угол. В реальности недостижим полностью, но направление движения задаёт именно он.
Практический критерий качества¶
Формулировка, которую стоит записать: архитектура хороша, если модуль можно удалить. Не изменить — именно удалить. Если выпиливание модуля затрагивает две-три точки и не рушит систему — границы проведены верно.
Это отличный тест на код-ревью: «покажи, что сломается, если мы завтра выкинем этот модуль».
Определение¶
Архитектура ПО — это набор модулей (компонентов) системы, правила их разработки, описание связей между ними и интерфейсы, чётко фиксирующие назначение каждого модуля.
Чем архитектура не является:
- ❌ структурой папок и файлов — это её следствие, а не она сама;
- ❌ списком применённых технологий («у нас архитектура на React и Postgres» — это стек);
- ❌ диаграммой в Confluence, которую никто не открывал с прошлого года.
Чем является дополнительно, если работаете командой: общий язык и предсказуемость. Все пишут единообразно, ревью проходит быстрее, новичку можно дать документ вместо трёхмесячного пересказа, и никто не изобретает свой велосипед на каждую задачу.
Мера, которую можно посчитать¶
Чтобы это не звучало абстрактно, метрики Мартина (их считают, например, NDepend и SonarQube):
- Ca (afferent coupling) — сколько модулей зависят от вас;
- Ce (efferent coupling) — от скольких зависите вы;
- I = Ce / (Ca + Ce) — нестабильность: 0 — «на мне всё держится, меня нельзя менять», 1 — «я ни на что не влияю, меня можно менять свободно».
Правило: зависимости должны идти в сторону более стабильных модулей. Домен стабилен (I → 0), инфраструктура нестабильна (I → 1). Если домен ссылается на инфраструктуру — стрелка направлена не туда, и это ровно тот же DIP из лекции 1, только на уровне сборок.
Блок 3. Уровни архитектуры¶
Слово «архитектура» произносят на трёх разных высотах, и путаница обычно отсюда.
Системный уровень. Монолит, модульный монолит, микросервисы, событийно-ориентированная система. Здесь модуль — это разворачиваемая единица: сервис за шлюзом, очередь, база. Обсуждаются сеть, отказы, транзакции между сервисами, версионирование контрактов.
Уровень приложения. Внутренность одного сервиса: слои, домен, порты и адаптеры, MVC. Именно этим мы занимаемся в курсе.
Уровень модуля/класса. SOLID, паттерны GoF, «запахи» — то, что было в лекции 1 и будет во второй половине сегодня.
Важная мысль: микросервисы не отменяют архитектуру внутри сервиса. Двадцать сервисов, каждый из которых внутри God Object — это двадцать проблем вместо одной плюс сеть между ними. Обратное тоже верно: хорошо промодуляризованный монолит режется на сервисы тогда, когда это понадобится, а не «на всякий случай в самом начале».
В C#-мире границы модулей естественно ложатся на проекты (
.csproj): зависимости между сборками проверяет компилятор, и «случайно» сослаться на инфраструктуру из домена не выйдет. Дешёвый способ сделать архитектуру не рекомендацией, а ограничением.Аналоги: Java — модули JPMS и Gradle-подпроекты; Go — internal-пакеты, которые физически нельзя импортировать снаружи; Rust — видимость
pub(crate); TypeScript — project references плюс правилаeslint-plugin-boundaries. В Python границы держатся на договорённостях — компилятор не поможет, поэтому там особенно полезны тесты архитектуры (import-linter).
Тесты на архитектуру в C# — отдельный приятный трюк: библиотека NetArchTest или ArchUnitNET позволяет написать в юнит-тестах правило и уронить сборку при нарушении:
[Fact]
public void Домен_не_должен_знать_об_инфраструктуре()
{
var result = Types.InAssembly(typeof(Order).Assembly)
.That().ResideInNamespace("Shop.Domain")
.ShouldNot().HaveDependencyOn("Shop.Infrastructure")
.GetResult();
Assert.True(result.IsSuccessful);
}
Правило, которое нельзя нарушить незаметно, стоит десяти страниц документации.
Блок 4. MVC: разделение интерфейса и логики¶
Идея¶
MVC придуман в 1978 году (Трюгве Реенскауг, Xerox PARC), и его единственная исходная мысль: отделить бизнес-логику от интерфейса.
- Бизнес-логика — то, что приносит пользу продукту: регистрация пользователя с правилами хеширования пароля, добавление товара в корзину, расчёт скидки, проведение оплаты.
- Интерфейс — то, что пользователь видит и трогает: формы, кнопки, тексты, экраны.
Звучит очевидно, но в 1978-м очевидным не было, да и сейчас не для всех: проекты, где SQL-запрос живёт в обработчике нажатия кнопки, встречаются регулярно.
Задача, на которой это становится видно¶
Мы делаем калькулятор. Приходит менеджер: нужен новый дизайн, но старым пользователям оставить привычный, с переключателем. То есть два интерфейса и одна логика вычислений.
Если логика вшита в обработчики кнопок — мы копируем её во второй интерфейс, и с этого момента у нас два синуса, которые разъедутся при первом же баг-фиксе. Если логика вынесена — новый интерфейс это просто новая разметка, дергающая то же ядро.
Роли¶
- Model — бизнес-правила и данные. Единственный источник истины. Про UI не знает ничего.
- View — отображение. Своя логика у него есть (отрисовать список, подсветить кнопку при условии), но это логика отображения, и смешивать её с бизнес-логикой нельзя.
- Controller — тонкое связующее звено. Принимает ввод, валидирует его, вызывает модель, готовит данные для представления.
Аналогия с банком: банкомат и консультант — это View, внутреннее устройство банка (правила перевода, конвертации, выдачи) — Model, а система внутри банкомата и знание консультанта «к какому специалисту вас отправить» — Controller. Экран банкомата про правила конвертации не знает ничего.
Правило контроллера: он всегда тонкий. Максимум — валидация входных данных, логирование,
маппинг, формирование ответа. Если в контроллере появился if про предметную область —
он уехал в модель.
Клиент-серверный поток¶
Пользователь заполняет форму, жмёт «Сохранить» → POST → контроллер валидирует → передаёт в модель → модель применяет правила → контроллер рендерит представление → пользователь видит новую страницу.
Зачем здесь контроллер, если модель могла бы отдать данные сразу во View? Затем, что контроллеров может быть много при одной модели: MVC-контроллер отдаёт HTML, REST-контроллер — JSON партнёрам, GraphQL-эндпоинт — свою схему, SOAP-адаптер — XML. Модель одна, оболочек много.
Пример на C# (ASP.NET Core)¶
// ── Model: бизнес-правила. Ничего не знает про HTTP, HTML и ASP.NET ──────────
public class UserService
{
private readonly List<User> _users = new();
public IReadOnlyList<User> GetAll() => _users;
public User Create(string username, int age)
{
if (_users.Any(u => u.Username == username))
throw new DomainException($"Пользователь {username} уже существует");
var user = new User(Guid.NewGuid(), username, age); // правила внутри User
_users.Add(user);
return user;
}
public void Remove(Guid id)
{
var user = _users.FirstOrDefault(u => u.Id == id)
?? throw new DomainException("Пользователь не найден");
_users.Remove(user);
}
}
// ── Controller: тонкий. Разбирает запрос, зовёт модель, выбирает представление ─
public class UsersController : Controller
{
private readonly UserService _users;
public UsersController(UserService users) => _users = users; // DI из лекции 1
[HttpGet("/users")]
public IActionResult Index() => View(_users.GetAll());
[HttpPost("/users/create")]
public IActionResult Create(CreateUserForm form)
{
if (!ModelState.IsValid) return View("Error", "Заполните имя и возраст");
try
{
_users.Create(form.Username, form.Age);
return RedirectToAction(nameof(Index));
}
catch (DomainException ex)
{
return View("Error", ex.Message); // обработка — да, бизнес-правила — нет
}
}
}
// ── Ещё один контроллер поверх ТОЙ ЖЕ модели ────────────────────────────────
[ApiController]
public class UsersApiController : ControllerBase
{
private readonly UserService _users;
public UsersApiController(UserService users) => _users = users;
[HttpGet("/api/users")]
public IReadOnlyList<User> GetAll() => _users.GetAll(); // тот же вызов, другой формат
}
@* ── View: логика отображения и только она ── *@
@model IReadOnlyList<User>
<form method="post" action="/users/create">
<input name="username" placeholder="Имя" />
<input name="age" type="number" placeholder="Возраст" />
<button type="submit">Отправить</button>
</form>
<ul>
@foreach (var user in Model)
{
<li class="@(user.Age < 18 ? "minor" : "")">@user.Username, @user.Age</li>
}
</ul>
Обратите внимание на class="@(user.Age < 18 ? ...)". Это условие — логика отображения,
и место ему здесь. А вот if (user.Age < 18) throw — бизнес-правило, и ему место в User.
Граница проходит по вопросу: изменится ли это правило, если мы поменяем интерфейс?
Если нет — это модель.
MVC на фронтенде без фреймворков (TypeScript)¶
Тот же паттерн применим внутри SPA. Каркас на TS без React/Vue, чтобы видеть чистую схему:
// Model — только правила и данные
class CounterModel {
private value = 0;
get current() { return this.value; }
increment() { this.value += 1; }
multiply(by: number) {
if (by === 0) throw new Error("Умножение на ноль обнуляет счётчик");
this.value *= by;
}
}
// Controller — тонкая прослойка, знает про модель
class CounterController {
constructor(private model: CounterModel) {}
handleIncrement(): number { this.model.increment(); return this.model.current; }
handleMultiply(raw: string): number {
const by = Number(raw);
if (Number.isNaN(by)) throw new Error("Введите число"); // валидация ввода — ок
this.model.multiply(by);
return this.model.current;
}
}
// View — DOM и ничего больше, зовёт контроллер
class CounterView {
constructor(private root: HTMLElement, private controller: CounterController) {}
mount() {
const title = document.createElement("h2");
const plus = document.createElement("button");
plus.textContent = "+1";
plus.addEventListener("click", () => {
title.textContent = String(this.controller.handleIncrement());
});
this.root.append(title, plus);
}
}
// Сборка — снова композиционный корень
const model = new CounterModel();
const view = new CounterView(document.getElementById("app")!, new CounterController(model));
view.mount();
Холивар, который стоит проговорить¶
В интернете гуляют две схемы MVC:
- View → Controller → Model → Controller → View. Модель про View не знает вообще.
- View → Controller → Model, и Model сама уведомляет View об изменениях (классический MVC из Smalltalk, где View подписан на модель через Observer).
Первая даёт более слабое зацепление: View и Model взаимозаменяемы независимо. Вторая честнее
для интерактивных интерфейсов, где на одну модель смотрят несколько представлений — но только
если уведомление идёт через события/подписку, а не через прямую ссылку model.view.update().
Прямая ссылка из модели на конкретное представление — это уже запах Inappropriate Intimacy
(блок 7).
Блок 5. MVVM и MVP¶
MVP (Model–View–Presenter). Presenter вместо контроллера, View общается с ним через
интерфейс IView. За счёт этого View подменяется заглушкой, и презентер тестируется без UI.
Классика для WinForms и старого Android.
MVVM (Model–View–ViewModel). Применим почти исключительно к графическим интерфейсам.
Вместо контроллера — ViewModel, а ключевая идея — двустороннее связывание данных (data
binding): пользователь меняет значение в контроле → меняется свойство ViewModel; код меняет
свойство → обновляется контрол. Никто не пишет label.Text = ... руками.
Поток: View ⇄ (binding) ⇄ ViewModel → Model → уведомление → ViewModel → View.
// C# / WPF, MAUI, Avalonia — родная среда MVVM
public partial class UserViewModel : ObservableObject // CommunityToolkit.Mvvm
{
private readonly UserService _users; // Model
[ObservableProperty] private string username = ""; // генерируется свойство + уведомление
[ObservableProperty] private int age;
[ObservableProperty] private string? error;
public UserViewModel(UserService users) => _users = users;
[RelayCommand] // превращается в ICommand для кнопки
private void Create()
{
try { _users.Create(Username, Age); Error = null; }
catch (DomainException ex) { Error = ex.Message; }
}
}
<!-- View: связывание вместо императивного обновления -->
<StackPanel>
<TextBox Text="{Binding Username, UpdateSourceTrigger=PropertyChanged}" />
<TextBox Text="{Binding Age}" />
<Button Content="Создать" Command="{Binding CreateCommand}" />
<TextBlock Text="{Binding Error}" Foreground="Firebrick" />
</StackPanel>
Механика связывания в C# держится на интерфейсе INotifyPropertyChanged — сеттер свойства
поднимает событие, к которому подписан контрол.
Как это выглядит в других языках. Vue делает то же самое через
v-modelи реактивные прокси — там связывание встроено в реактивность языка выполнения. Svelte идёт дальше и компилирует присваиваниеcount = count + 1в обновление DOM, вообще без рантайм-подписок. В SwiftUI связывание выражено типом:@Stateи@Binding— это специальные обёртки, и$valueдаёт «ссылку на свойство» как значение первого класса. В C# ближайший аналог такой явности — генераторы кода ([ObservableProperty]), которые пишут за васINotifyPropertyChanged.
Когда что. MVC — запрос-ответ, серверные приложения, много форматов вывода. MVVM — богатый интерфейс с состоянием и связыванием. MVP — когда View нужно тестировать через заглушку. Идея у всех одна: отделить то, что видит пользователь, от того, что решает система. Различается только форма связующего звена.
Блок 6. Рефакторинг: правила игры¶
Определение¶
Рефакторинг — изменение внутренней структуры кода, не меняющее его внешнего поведения, ради упрощения понимания и удешевления дальнейших изменений.
Оба условия обязательны. Переписали модуль и заодно поправили баг — это не рефакторинг, а «переписали модуль и поправили баг»: если что-то сломается, вы не будете знать, что именно виновато.
Что рефакторингом не является¶
- ❌ Переписывание с нуля («большой перепил»). Это не рефакторинг, а новый проект с уже известными требованиями и неизвестными сроками.
- ❌ Оптимизация производительности. Другая цель, другие критерии, часто противоположное направление (быстрый код нередко менее читаем).
- ❌ Добавление фич «пока я тут».
Технический долг¶
Метафора Уорда Каннингема: быстрое решение сегодня — это кредит. Пользоваться им можно и часто нужно (успеть к релизу, проверить гипотезу), но по нему идут проценты: каждая последующая задача в этом месте дороже предыдущей. Рефакторинг — выплата тела долга.
Долг бывает осознанным («знаем, что костыль, заведён тикет») и неосознанным («мы думали, что так правильно»). Опасен второй.
Когда рефакторить¶
- Правило трёх (Фаулер). Первый раз — просто делаете. Второй раз, морщась, повторяете. На третий — рефакторите.
- При добавлении фичи. Сначала привести код в форму, где фича добавляется легко, потом добавить фичу. Отдельными коммитами.
- При исправлении бага. Баг часто и есть указатель на запах.
- На код-ревью. Дешевле всего.
- ⚠️ Не рефакторим то, что скоро выкинут, и не начинаем рефакторинг за день до релиза.
Страховка¶
Рефакторинг без тестов — это редактирование вслепую. Порядок:
- Убедиться, что поведение покрыто тестами (нет — сначала написать характеризующие тесты на текущее поведение, даже если оно кажется странным).
- Маленькие шаги: одно преобразование — прогон тестов — коммит.
- Не смешивать с изменением функциональности: отдельные коммиты, чтобы ревьюер видел, где поведение менялось, а где нет.
В C# многое из этого делает IDE: Ctrl+. в Visual Studio / Ctrl+Alt+Shift+T в Rider
выполняют извлечение метода, переименование, введение параметра безопасно — с учётом
семантики Roslyn, а не текстовым поиском. Автоматическому переименованию доверять можно;
ручному «найти и заменить» — нет.
Что такое «запах»¶
Запах кода — не ошибка. Программа работает. Это признак, что через N изменений здесь будет больно. Запах указывает где посмотреть, а не что обязательно переделать: иногда длинный метод — это честно длинный алгоритм, и трогать его хуже.
Дальше — каталог по классификации Refactoring.Guru: пять групп, двадцать три запаха.
Блок 7. Каталог запахов кода¶
Группа 1. Раздувальщики (Bloaters)¶
Код, методы и классы, разросшиеся до размеров, при которых с ними невозможно работать. Появляются не сразу, а накапливаются годами.
Long Method — длинный метод. Главный подозреваемый. Эвристика: метод не помещается на экран; внутри есть комментарии, объясняющие «а вот тут мы считаем скидку» — каждый такой комментарий это готовое имя метода. Приёмы: Extract Method, Replace Temp with Query, Decompose Conditional, Replace Nested Conditional with Guard Clauses, Replace Method with Method Object.
// ДО:
public decimal CalculateTotal(Order order)
{
decimal total = 0;
foreach (var line in order.Lines)
{
decimal price = line.Quantity * line.UnitPrice;
if (line.Quantity > 10) price *= 0.95m; // оптовая скидка
total += price;
}
if (order.Customer.IsVip) total *= 0.9m; // VIP-скидка
if (total > 1000) total -= 50; // купон
if (order.Country != "MD") total += total * 0.2m; // НДС для экспорта
return total;
}
// ПОСЛЕ: имена вместо комментариев, каждый шаг проверяем отдельно
public decimal CalculateTotal(Order order)
{
var subtotal = order.Lines.Sum(LinePriceWithBulkDiscount);
subtotal = ApplyCustomerDiscount(subtotal, order.Customer);
subtotal = ApplyCoupon(subtotal);
return ApplyTax(subtotal, order.Country);
}
private static decimal LinePriceWithBulkDiscount(OrderLine line)
=> line.Quantity > 10
? line.Quantity * line.UnitPrice * 0.95m
: line.Quantity * line.UnitPrice;
Large Class — большой класс. Тот самый God Object, только на уровне класса: слишком много полей, методов и причин для изменения. Сигнал: чтобы понять класс, надо прокручивать. Приёмы: Extract Class, Extract Subclass, Extract Interface.
Primitive Obsession — одержимость элементарными типами. Деньги как decimal, email как
string, статус как int с константами, координаты как два double. Проблема: правила
валидации размазываются по всем местам использования, а компилятор не мешает передать email
туда, где ждали телефон.
Приёмы: Replace Data Value with Object, Replace Type Code with Class/Subclasses,
Introduce Parameter Object.
// ДО
public void Register(string email, decimal amount, string currency) { ... }
Register(user.Phone, 100m, "MDL"); // компилируется. и это беда
// ПОСЛЕ: значимые типы, невалидное состояние невыразимо
public readonly record struct Email
{
public string Value { get; }
public Email(string value)
{
if (!value.Contains('@')) throw new ArgumentException("Некорректный email");
Value = value;
}
public override string ToString() => Value;
}
public readonly record struct Money(decimal Amount, Currency Currency)
{
public static Money operator +(Money a, Money b) =>
a.Currency == b.Currency
? new Money(a.Amount + b.Amount, a.Currency)
: throw new InvalidOperationException("Разные валюты не складываются");
}
public void Register(Email email, Money amount) { ... }
Register(user.Phone, ...); // теперь ошибка компиляции
Где это красивее. В F# единицы измерения встроены в систему типов:
[<Measure>] type mи[<Measure>] type s— и10.0<m> / 2.0<s>даёт5.0<m/s>, а сложение метров с секундами не компилируется, причём без единого рантайм-класса. В TypeScript тот же эффект достигают «брендированными» типами:type Email = string & { readonly __brand: unique symbol }. В Python —NewType("Email", str), проверяется только mypy. В C# ближайшее —readonly record struct: обёртка ценой нуля аллокаций.
Long Parameter List — длинный список параметров. Больше трёх-четырёх параметров — вызов невозможно прочитать, а порядок аргументов начинают путать. Приёмы: Introduce Parameter Object, Preserve Whole Object, Replace Parameter with Method Call.
Data Clumps — группы данных. Одни и те же 3–4 параметра кочуют вместе по сигнатурам
(street, city, zip или startDate, endDate). Признак пропущенного класса.
Приёмы: Extract Class, Introduce Parameter Object, Preserve Whole Object.
// ДО
void Book(string city, string street, string zip, DateTime from, DateTime to) { }
// ПОСЛЕ: два пропущенных понятия найдены
public record Address(string City, string Street, string Zip);
public record DateRange(DateTime From, DateTime To)
{
public DateRange { if (To < From) throw new ArgumentException("Конец раньше начала"); }
public int Nights => (To - From).Days; // поведение переехало к данным
}
void Book(Address address, DateRange stay) { }
Именованные аргументы C# (
Book(city: "Кишинёв", street: ...)) лечат читаемость вызова, но не саму проблему — группа данных как была разрозненной, так и осталась. В Python можно заставить вызывающего использовать имена —def book(*, city, street, zip); в Kotlin у параметров есть значения по умолчанию, и оба приёма маскируют запах, а не убирают.
Группа 2. Нарушители объектного дизайна (OO Abusers)¶
Неполное или неверное использование возможностей ООП.
Switch Statements — операторы switch. Один и тот же switch по типу/коду сущности
разбросан по нескольким местам: добавили новый вариант — обошли все места (и одно забыли).
Приёмы: Replace Conditional with Polymorphism, Replace Type Code with Subclasses/State/Strategy,
Introduce Null Object.
// ДО: этот switch продублирован ещё в трёх местах
decimal GetSalary(Employee e) => e.Type switch
{
EmployeeType.Engineer => e.BaseSalary,
EmployeeType.Manager => e.BaseSalary * 1.2m,
EmployeeType.Sales => e.BaseSalary + e.Bonus,
_ => throw new ArgumentOutOfRangeException()
};
// ПОСЛЕ: поведение живёт рядом с типом
public abstract class Employee
{
public decimal BaseSalary { get; init; }
public abstract decimal Salary { get; }
}
public class Engineer : Employee { public override decimal Salary => BaseSalary; }
public class Manager : Employee { public override decimal Salary => BaseSalary * 1.2m; }
Оговорка:
switch— это запах, а не преступление. Если вариантов мало и меняются они одним куском, паттерн-матчинг C# 9+ читается лучше иерархии из пяти классов:order switch { { Total: > 1000 } => ..., { Customer.IsVip: true } => ... }. Ключевая разница с языками вроде Rust и F#: там компилятор проверяет исчерпывающность сопоставления, и забытый вариант — ошибка сборки, а неdefault: throwв рантайме. В C# такую проверку частично даётswitch-выражение над иерархией сsealed-типами плюс анализатор; в Pythonmatchисчерпывающность не проверяет вовсе.
Temporary Field — временное поле. Поле, заполняемое только на время работы одного алгоритма, а остальное время пустое. Читатель класса вынужден гадать, когда оно валидно. Приёмы: Extract Class, Replace Method with Method Object, Introduce Null Object.
Refused Bequest — отказ от наследства. Подкласс использует малую часть унаследованного,
а ненужное переопределяет заглушками или throw new NotSupportedException(). Прямое
нарушение LSP из лекции 1.
Приёмы: Replace Inheritance with Delegation, Extract Superclass.
В Go наследования классов нет вообще — только встраивание структур и интерфейсы, так что этот запах там физически невозможен. Хороший аргумент в дискуссии «наследование против композиции»: целый язык обходится без первого.
Alternative Classes with Different Interfaces — альтернативные классы с разными
интерфейсами. Два класса делают одно и то же, но методы называются по-разному
(SaveUser / PersistCustomer), поэтому подменить один другим нельзя.
Приёмы: Rename Method, Move Method, Extract Superclass.
Группа 3. Утяжелители изменений (Change Preventers)¶
Изменение в одном месте требует правок в куче других.
Divergent Change — расходящиеся модификации. Один класс меняется по многим разным причинам: сегодня из-за новой валюты, завтра из-за новой формы отчёта. Обратная сторона SRP. Приёмы: Extract Class.
Shotgun Surgery — стрельба дробью. Зеркальная беда: одно изменение требует мелких правок в десятке классов. Добавили поле в пользователя — правим DTO, маппер, валидатор, миграцию, три представления. Приёмы: Move Method, Move Field, Inline Class.
Parallel Inheritance Hierarchies — параллельные иерархии. Создаёте подкласс в одной
иерархии — обязаны создать парный в другой (OrderXml/OrderXmlParser).
Приёмы: Move Method, Move Field — свести иерархии в одну.
В C# запах Divergent Change часто маскируется
partial-классами: класс на 3000 строк выглядит как пять аккуратных файлов. Файлы разные — причина для изменения по-прежнему одна и та же куча. Тот же эффект в Kotlin/Swift дают extensions, в Ruby — открытые классы.
Группа 4. Замусориватели (Dispensables)¶
Лишнее, от чего можно избавиться, и код станет чище.
Comments — комментарии. Не любые: плохи те, что объясняют как работает непонятный код. Такой комментарий — дезодорант. Хорошие комментарии объясняют почему принято неочевидное решение и остаются. Приёмы: Extract Method, Rename Method, Introduce Assertion.
Duplicate Code — дублирование. Самый частый запах. Осторожно: случайное совпадение двух фрагментов — не дубликат. Дубликат — то, что обязано меняться одновременно. Приёмы: Extract Method, Pull Up Method, Extract Class, Substitute Algorithm.
Lazy Class — ленивый класс. Класс, который почти ничего не делает и не оправдывает стоимость своего существования. Приёмы: Inline Class, Collapse Hierarchy.
Data Class — класс данных. Только поля и аксессоры, никакого поведения; логику над этими данными пишут снаружи. Часто идёт в паре с Feature Envy. Приёмы: Move Method, Encapsulate Field, Encapsulate Collection.
Важная оговорка для C#: DTO, контракты API и
record-типы — это не запах. Запах начинается, когда рядом заводитсяOrderHelper, который делает всё то, что должен был бы делать самOrder.recordв C#,data classв Kotlin и@dataclassв Python сделали дешёвым создание носителей данных — и заодно упростили создание анемичной модели.
Dead Code — мёртвый код. Неиспользуемые методы, классы, поля, ветки условий. «Оставим, вдруг понадобится» — для этого есть система контроля версий. Приёмы: удалить; Inline Class, Collapse Hierarchy, Remove Parameter.
Speculative Generality — теоретическая общность. Абстракции, созданные «на будущее», которое не наступило: интерфейс с единственной реализацией, точки расширения, которыми никто не пользуется, параметры на все случаи жизни. Приёмы: Collapse Hierarchy, Inline Class, Remove Parameter, Rename Method.
Это прямой конфликт с Open/Closed: OCP просит заранее заложить оси изменений, а YAGNI — не закладывать ничего лишнего. Разрешение конфликта: абстракция оправдана, когда есть две реальные реализации или подтверждённое требование, а не предчувствие.
Группа 5. Опутыватели связями (Couplers)¶
Избыточная связанность между классами — либо, наоборот, её замена бесконечным делегированием.
Feature Envy — завистливые функции. Метод больше обращается к данным чужого класса, чем своего. Приёмы: Move Method, Extract Method.
// ДО: отчёт «завидует» заказу
public class Report
{
public decimal TotalFor(Order order)
=> order.Lines.Sum(l => l.Quantity * l.UnitPrice) - order.Discount + order.Shipping;
}
// ПОСЛЕ: считает тот, у кого данные
public class Order
{
public decimal Total => Lines.Sum(l => l.Quantity * l.UnitPrice) - Discount + Shipping;
}
Inappropriate Intimacy — неуместная близость. Классы копаются во внутренностях друг друга, знают о приватных деталях, ходят «в обе стороны». Приёмы: Move Method, Move Field, Extract Class, Hide Delegate, Replace Inheritance with Delegation.
Message Chains — цепочки вызовов. order.GetCustomer().GetAddress().GetCity().GetName().
Клиент завязан на всю цепочку структуры: изменится середина — сломается вызывающий.
Это же «закон Деметры»: разговаривай только с непосредственными соседями.
Приёмы: Hide Delegate, Extract Method, Move Method.
В C# оператор
?.делает цепочки внешне безопасными —order?.Customer?.Address?.City— и потому особенно коварен: запах остаётся, а боль (NRE) исчезает, так что чинить его перестают. То же с?.в TypeScript и?.letв Kotlin.
Middle Man — посредник. Класс, который только делегирует и ничего не добавляет. Обратная крайность к Message Chains: скрывая делегирование, легко наплодить пустых прослоек. Приёмы: Remove Middle Man, Inline Method, Replace Delegation with Inheritance.
В Kotlin делегирование встроено в язык:
class Logger(db: Repo) : Repo by dbавтоматически проксирует все методы интерфейса — посредник обходится в одну строку и перестаёт быть проблемой сопровождения. В C# такого нет: каждый метод придётся прописать руками (или сгенерировать сорс-генератором) — что, впрочем, полезно: цена пустой прослойки сразу видна.
Incomplete Library Class — неполнота библиотечного класса. Библиотечный класс делает почти всё, что нужно, но не хватает пары методов, а исходники править нельзя. Приёмы: Introduce Foreign Method, Introduce Local Extension.
Здесь C# особенно удобен: методы расширения решают проблему штатно —
public static bool IsWeekend(this DateTime d) => .... В Kotlin и Swift есть свои extensions, в Python — обёртки или наследование, в Ruby — monkey patching (быстро, но опасно: правит класс глобально для всего процесса).
Блок 8. Приёмы и инструменты¶
Восемь приёмов, которых хватает на 80% случаев¶
- Extract Method — выделить кусок в метод с говорящим именем. Самый частый ход.
- Extract Class — выделить группу полей и работающих с ними методов в новый класс.
- Move Method / Move Field — перенести к тем данным, которыми оперирует.
- Introduce Parameter Object — свернуть группу параметров в тип.
- Replace Temp with Query — временную переменную заменить методом-вычислением.
- Replace Nested Conditional with Guard Clauses — вывернуть вложенные
ifв ранние возвраты. - Replace Conditional with Polymorphism —
switchпо типу превратить в иерархию или стратегию. - Replace Inheritance with Delegation — снять неудачное наследование, оставив композицию.
// Guard clauses: было 4 уровня вложенности
public decimal Discount(Customer c)
{
if (c is null) return 0;
if (!c.IsActive) return 0;
if (c.Orders.Count == 0) return 0;
return c.IsVip ? 0.2m : 0.05m; // основная мысль — на нулевом уровне вложенности
}
Инструменты в экосистеме .NET¶
| Задача | Инструмент |
|---|---|
| Безопасные преобразования | Rider / Visual Studio: Extract Method, Rename, Introduce Parameter (на базе Roslyn) |
| Статический анализ | Roslyn Analyzers, <TreatWarningsAsErrors>, .editorconfig |
| Метрики и дублирование | SonarQube / SonarLint, NDepend (Ca, Ce, цикломатическая сложность) |
| Границы модулей | NetArchTest, ArchUnitNET — правила зависимостей как юнит-тесты |
| Единый стиль | dotnet format, .editorconfig в репозитории |
| Страховка | xUnit/NUnit + coverlet; для легаси — характеризующие тесты |
Аналоги в других экосистемах: ESLint + ts-prune (мёртвый код), ruff/pylint и
import-linter в Python, detekt в Kotlin, golangci-lint в Go.
Практическое правило: всё, что можно проверить автоматически, не должно обсуждаться на ревью. Форматирование, сложность метода, запрещённые зависимости — в конфиг анализатора. На ревью остаются имена, границы и решения.
Блок 9. Запах → принцип → архитектурный дефект¶
Замыкаем обе половины лекции. Один и тот же дефект виден на трёх масштабах:
| Запах (класс) | Нарушенный принцип | Как выглядит в архитектуре |
|---|---|---|
| Large Class, Divergent Change | SRP | Модуль без чёткой зоны ответственности, «Common», «Core» |
| Switch Statements | OCP | Новый тип клиента требует правок в пяти модулях |
| Refused Bequest | LSP | Реализация «общего» интерфейса, которая на деле умеет не всё |
| Alternative Classes with Different Interfaces | ISP | Два сервиса делают одно, но контракты несовместимы |
| Feature Envy, Message Chains, Inappropriate Intimacy | DIP, закон Деметры | Модули знают о внутреннем устройстве друг друга |
| Shotgun Surgery | высокое зацепление | Одна фича — коммит в шесть модулей |
| Duplicate Code | DRY, слабая связность | Одно правило продублировано в трёх сервисах и разъехалось |
Обратный ход тоже работает: если релиз фичи требует изменений в пяти модулях — начинайте искать конкретные запахи там, где эти изменения приходится делать. Запах — это симптом, принцип — диагноз, архитектура — прогноз.
Возвращаясь к критерию из блока 2: система, в которой запахи не накапливаются, — это система, из которой модуль можно вынуть. Всё, что мы сегодня перечислили, служит одной цели: сохранить возможность удалить.
Шпаргалка: запах → первый приём¶
Двадцать три запаха одной таблицей — для поиска во время работы.
| Запах | Группа | Первый приём | Сигнал в C# |
|---|---|---|---|
| Long Method | Раздувальщики | Extract Method | метод не влезает на экран; комментарии-заголовки внутри |
| Large Class | Раздувальщики | Extract Class | десятки полей, регионы #region вместо структуры |
| Primitive Obsession | Раздувальщики | Replace Data Value with Object | string email, int status, валидация в каждом вызове |
| Long Parameter List | Раздувальщики | Introduce Parameter Object | больше 4 параметров; спасаются именованными аргументами |
| Data Clumps | Раздувальщики | Extract Class | одни и те же 3 параметра в куче сигнатур |
| Switch Statements | Нарушители ООП | Replace Conditional with Polymorphism | один switch по enum в нескольких местах |
| Temporary Field | Нарушители ООП | Extract Class | поле заполняется только в одном методе |
| Refused Bequest | Нарушители ООП | Replace Inheritance with Delegation | throw new NotSupportedException() в override |
| Alternative Classes… | Нарушители ООП | Rename Method, Extract Superclass | SaveUser и PersistCustomer делают одно |
| Divergent Change | Утяжелители | Extract Class | класс правят по трём разным причинам; много partial |
| Shotgun Surgery | Утяжелители | Move Method, Move Field | одно изменение — правки в 6 файлах |
| Parallel Inheritance Hierarchies | Утяжелители | Move Method | новый подкласс требует парного в другой иерархии |
| Comments | Замусориватели | Extract Method, Rename Method | комментарий объясняет «как», а не «почему» |
| Duplicate Code | Замусориватели | Extract Method, Pull Up Method | одинаковые правила в разных слоях |
| Lazy Class | Замусориватели | Inline Class | класс из одного метода-пробрасывателя |
| Data Class | Замусориватели | Move Method | record + SomethingHelper рядом с ним |
| Dead Code | Замусориватели | удалить | #if false, неиспользуемые публичные методы |
| Speculative Generality | Замусориватели | Collapse Hierarchy, Inline Class | интерфейс с одной реализацией «на будущее» |
| Feature Envy | Опутыватели | Move Method | метод дергает чужие свойства чаще своих |
| Inappropriate Intimacy | Опутыватели | Move Method, Hide Delegate | internal-доступ ради «соседнего» класса |
| Message Chains | Опутыватели | Hide Delegate | a?.B?.C?.D в бизнес-коде |
| Middle Man | Опутыватели | Remove Middle Man | сервис, где каждый метод — одна строка вызова |
| Incomplete Library Class | Прочие | Introduce Local Extension | обёртка вокруг класса ради двух методов |
Итоги лекции¶
- Стоимость изменений растёт нелинейно из-за неявных связей — это и есть проблема, которую решает архитектура.
- Цель — сильная связность внутри модуля и слабое зацепление между модулями.
- Рабочий критерий качества: модуль должно быть можно удалить.
- Архитектура — это модули, их связи и контракты; папки и стек — не архитектура.
- Архитектура существует на трёх уровнях; микросервисы не отменяют порядок внутри сервиса.
- MVC отделяет бизнес-логику от интерфейса; контроллер тонкий, модель — источник истины, а форматов вывода поверх одной модели может быть сколько угодно.
- MVVM — то же разделение для графических интерфейсов через двустороннее связывание.
- Рефакторинг меняет структуру, не меняя поведения; идёт маленькими шагами под тестами и отдельно от новых фич.
- Запахи — карта проблемных мест: пять групп, двадцать три позиции.
- Запах на уровне класса и архитектурный дефект — одно и то же явление в разном масштабе.
Вопросы для самопроверки¶
- Чем связность отличается от зацепления? Приведите пример модуля с сильным зацеплением и слабой связностью.
- Почему критерий «модуль можно удалить» строже, чем «модуль можно изменить»?
- Ваш
UsersControllerсодержит проверку «пользователь не может быть младше 18». Где ошибка и куда переносить? - Одна модель — четыре контроллера (HTML, REST, GraphQL, SOAP). Что выиграли и что усложнили?
- В чём принципиальная разница между MVC и MVVM и почему MVVM не применяют на сервере?
- Почему исправление бага внутри рефакторинга — плохая практика?
- Дайте пример, когда длинный метод не нужно разбивать.
- Speculative Generality против Open/Closed: как решить, нужна ли абстракция сейчас?
- Почему
?.в C# считают маскировкой запаха Message Chains? - Какие запахи из каталога вы бы поручили анализатору, а какие принципиально требуют человека на ревью?
Домашнее задание¶
Работаем на общем учебном проекте LegacyShop — это маленький работающий интернет-магазин на C# (около 1200 строк), в который заранее заложены запахи из сегодняшнего каталога. Код и инструкция по запуску:
https://github.com/radilov-spsu/materials/tree/main/OOP/examples/LegacyShop
Проект собирается и запускается (dotnet run), ничего не требует, кроме .NET SDK.
Запахи не видны в выводе — программа работает; их нужно вычитать в коде.
Часть 1. Диагностика. Найти в LegacyShop не менее восьми запахов и
оформить таблицей: название по каталогу, файл и строки, почему это запах, какой
приём применили бы. Засчитываются только разные запахи — восемь Long Method в
восьми методах это одна позиция. Постарайтесь захватить разные группы
каталога, а не только раздувальщиков: они самые заметные, но не самые дорогие.
Часть 2. Рефакторинг. Устранить любые четыре из найденных. Требования:
- сначала характеризующие тесты на текущее поведение, потом изменения;
- один приём — один коммит, в сообщении указать название приёма
(
refactor: extract class Address (data clumps)); - поведение не меняется: тесты, написанные до, проходят после без правок.
Если по дороге найдёте настоящую ошибку (а она там есть), не чините её внутри рефакторинга — опишите отдельным пунктом в отчёте. Почему так — см. блок 6.