Перейти к содержанию

ООАП. Лекция 2 — Архитектура программных систем. Рефакторинг и устранение «запахов» кода

Конспект лектора. Архитектурные идеи и запахи кода от языка не зависят — они одинаково работают в C#, Java, Python и TypeScript. C# взят как язык примеров по знакомости аудитории; врезками показываю, где другой язык решает ту же проблему принципиально интереснее (Kotlin, F#, Go, Rust, TypeScript, Swift). Каталог запахов — по классификации Refactoring.Guru, приёмы — по Фаулеру.

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


Блок 1. Проблематика: почему код перестаёт поддаваться

Как это выглядит на практике

Начинаем проект без продуманной архитектуры. Появляется первый модуль — внутри него связи между компонентами. Появляется второй — связи возникают уже и между модулями. К четвёртому модулю связей столько, что ни один из них нельзя вынуть, не задев остальные.

Ключевое слово — неявные связи. Явную зависимость видно в конструкторе и в списке using; неявную — только когда что-то сломалось в другом конце приложения.

Три следствия, которые студенты должны запомнить:

  1. Удаление модуля становится невозможным. На него завязаны другие — прямо или через третьи руки.
  2. Изменение старого кода становится опасным. Open/Closed говорит «расширяй, не меняй», но бизнес устроен так, что старый код менять придётся. Вопрос лишь в цене.
  3. Добавление новой фичи замедляется. Не потому, что фича сложная, а потому, что непонятно, куда её класть и что она заденет.

Всё это растёт не линейно. Число потенциальных связей между 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 про предметную область — он уехал в модель.

Клиент-серверный поток

Браузер → GET /users → Controller → Model (данные) → Controller → View (HTML) → Браузер

Пользователь заполняет форму, жмёт «Сохранить» → 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:

  1. View → Controller → Model → Controller → View. Модель про View не знает вообще.
  2. 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. Рефакторинг: правила игры

Определение

Рефакторинг — изменение внутренней структуры кода, не меняющее его внешнего поведения, ради упрощения понимания и удешевления дальнейших изменений.

Оба условия обязательны. Переписали модуль и заодно поправили баг — это не рефакторинг, а «переписали модуль и поправили баг»: если что-то сломается, вы не будете знать, что именно виновато.

Что рефакторингом не является

  • ❌ Переписывание с нуля («большой перепил»). Это не рефакторинг, а новый проект с уже известными требованиями и неизвестными сроками.
  • ❌ Оптимизация производительности. Другая цель, другие критерии, часто противоположное направление (быстрый код нередко менее читаем).
  • ❌ Добавление фич «пока я тут».

Технический долг

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

Долг бывает осознанным («знаем, что костыль, заведён тикет») и неосознанным («мы думали, что так правильно»). Опасен второй.

Когда рефакторить

  • Правило трёх (Фаулер). Первый раз — просто делаете. Второй раз, морщась, повторяете. На третий — рефакторите.
  • При добавлении фичи. Сначала привести код в форму, где фича добавляется легко, потом добавить фичу. Отдельными коммитами.
  • При исправлении бага. Баг часто и есть указатель на запах.
  • На код-ревью. Дешевле всего.
  • ⚠️ Не рефакторим то, что скоро выкинут, и не начинаем рефакторинг за день до релиза.

Страховка

Рефакторинг без тестов — это редактирование вслепую. Порядок:

  1. Убедиться, что поведение покрыто тестами (нет — сначала написать характеризующие тесты на текущее поведение, даже если оно кажется странным).
  2. Маленькие шаги: одно преобразование — прогон тестов — коммит.
  3. Не смешивать с изменением функциональности: отдельные коммиты, чтобы ревьюер видел, где поведение менялось, а где нет.

В 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-типами плюс анализатор; в Python match исчерпывающность не проверяет вовсе.

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% случаев

  1. Extract Method — выделить кусок в метод с говорящим именем. Самый частый ход.
  2. Extract Class — выделить группу полей и работающих с ними методов в новый класс.
  3. Move Method / Move Field — перенести к тем данным, которыми оперирует.
  4. Introduce Parameter Object — свернуть группу параметров в тип.
  5. Replace Temp with Query — временную переменную заменить методом-вычислением.
  6. Replace Nested Conditional with Guard Clauses — вывернуть вложенные if в ранние возвраты.
  7. Replace Conditional with Polymorphismswitch по типу превратить в иерархию или стратегию.
  8. 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 обёртка вокруг класса ради двух методов

Итоги лекции

  1. Стоимость изменений растёт нелинейно из-за неявных связей — это и есть проблема, которую решает архитектура.
  2. Цель — сильная связность внутри модуля и слабое зацепление между модулями.
  3. Рабочий критерий качества: модуль должно быть можно удалить.
  4. Архитектура — это модули, их связи и контракты; папки и стек — не архитектура.
  5. Архитектура существует на трёх уровнях; микросервисы не отменяют порядок внутри сервиса.
  6. MVC отделяет бизнес-логику от интерфейса; контроллер тонкий, модель — источник истины, а форматов вывода поверх одной модели может быть сколько угодно.
  7. MVVM — то же разделение для графических интерфейсов через двустороннее связывание.
  8. Рефакторинг меняет структуру, не меняя поведения; идёт маленькими шагами под тестами и отдельно от новых фич.
  9. Запахи — карта проблемных мест: пять групп, двадцать три позиции.
  10. Запах на уровне класса и архитектурный дефект — одно и то же явление в разном масштабе.

Вопросы для самопроверки

  1. Чем связность отличается от зацепления? Приведите пример модуля с сильным зацеплением и слабой связностью.
  2. Почему критерий «модуль можно удалить» строже, чем «модуль можно изменить»?
  3. Ваш UsersController содержит проверку «пользователь не может быть младше 18». Где ошибка и куда переносить?
  4. Одна модель — четыре контроллера (HTML, REST, GraphQL, SOAP). Что выиграли и что усложнили?
  5. В чём принципиальная разница между MVC и MVVM и почему MVVM не применяют на сервере?
  6. Почему исправление бага внутри рефакторинга — плохая практика?
  7. Дайте пример, когда длинный метод не нужно разбивать.
  8. Speculative Generality против Open/Closed: как решить, нужна ли абстракция сейчас?
  9. Почему ?. в C# считают маскировкой запаха Message Chains?
  10. Какие запахи из каталога вы бы поручили анализатору, а какие принципиально требуют человека на ревью?

Домашнее задание

Работаем на общем учебном проекте 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.