Для начала создадим простую игровую модель. Нам понадобится класс Character, который умеет выполнять несколько базовых действий: атаковать, защищаться, подбирать монеты и сохранять свой прогресс.

У каждого персонажа есть свои характеристики: имя, здоровье, урон (насколько он бьёт при атаке), броня и количество монет.

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

«Игрок атаковал гоблина на 20»
​​​​​​​«Игрок подобрал монету».

Для проверки работы программы в методе Main() создайте двух персонажей — воина (Warrior) и гоблина (Goblin). Пусть воин атакует гоблина, а затем подберёт монетку. После этого выведите в консоль текущее здоровье гоблина.​​​​​​​

Уточнения:

1. Для воспроизведения звука добавьте метод-заглушку, который просто выводит сообщение в консоль:

​​​​​​​    private void PlaySound(string file)
    {
        Console.WriteLine($"Воспроизводится звук {file}");
    }

2. Для логирования каждого действия используйте метод:

private void LogAction(string message)
{
    File.AppendAllText("game.log", $"{DateTime.Now} {message}{Environment.NewLine}");
}

3. И для сохранения прогресса игрока:

private void SaveProgress()
{
    File.WriteAllText($"{Name}_save.txt", $"{Name};{Health};{Damage};{Armor};{Coins}");
}

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

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

И первый вопрос, с которого мы начнём:

Сколько причин для изменения имеет этот код?

или

При изменении какой логики мне придётся зайти в этот класс и что-то поменять?

Запомните этот вопрос. Он — ваш главный инструмент при анализе архитектуры. Каждый раз, когда вы смотрите на код, задавайте его себе. Со временем это станет вашим естественным мышлением — вы будете видеть зоны ответственности и чувствовать, где код начинает «течь».

Итак, смотрим на наше решение и отвечаем на главный вопрос — «Сколько причин для изменения имеет этот код?»

Загибаем пальцы.

Первая причина — изменение способа сохранения данных.

Сегодня мы сохраняем прогресс в текстовый файл, а завтра можем решить записывать его в базу данных или отправлять в облако. Для этого нам придётся изменять метод SaveProgress() внутри класса Character.

Вторая причина — изменение логики подбора монеты.

Например, поменяется звуковой файл, добавится визуальный эффект или система бонусов. Всё это затронет метод PickCoin() того же класса Character.

Третья причина — изменение способа логирования.

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

Четвёртая причина — изменение логики атаки или защиты.

Например, мы решим добавить критический удар, сделать урон зависящим от брони противника или добавить разные типы оружия. Всё это приведёт к изменениям в методах Attack() и Defend() внутри класса Character.

И что мы видим? Каждый из этих сценариев — это отдельная причина для изменения одного и того же класса. Получается, наш Character отвечает сразу и за игровую логику, и за сохранение данных, и за звук, и за логи. То есть у него не одна зона ответственности, а целых несколько — четыре причины для изменения.

Серьёзно? Ну хорошо, допустим, изменился звук при подборе монеты — я ведь всего лишь заменю имя файла, который проигрывается. Всего лишь одна строчка, ничего страшного, правда?

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

А этот класс, вообще-то, отвечает за логику персонажа, а не за то, как воспроизводятся звуки.

И вот здесь кроется настоящая проблема.

Сегодня вы меняете имя звукового файла. Завтра появится требование сделать разные звуки для разных типов монет. Послезавтра — добавится настройка громкости, а потом ещё и отключение звука в настройках игры. И всё это — снова изменения внутри Character.

Кроме того, если в команде есть тестировщик, ему действительно придётся тестировать весь класс полностью заново.

Если звук «привязан» к классу Character, то любое, даже самое маленькое изменение внутри него — формально меняет код класса.

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

Серьёзно? Я же просто поменял одну строчку кода! Я скажу тестировщику, что не нужно всё перепроверять!

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

Так устроен процесс обеспечения качества (QA): нельзя просто поверить на слово, что «я ничего не сломал».

Изменился код — значит, потенциально мог измениться и результат его работы.

Во-вторых, чем больше обязанностей у класса, тем выше риск, что вы случайно заденете что-то, даже не осознавая этого.

Одна невинная строчка — и вдруг не сохраняется прогресс, не пишется лог или звук вызывает ошибку при загрузке. То, что у нас получилось, — классический антипаттерн:

Наш Character — типичный God Object.

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

На первый взгляд — удобно, всё под рукой. Но на деле это превращается в настоящий хаос.

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

И вот мы и подошли к первому принципу:

Принцип единой ответственности (Single Responsibility Principle) говорит нам очень простую вещь:

У класса должна быть только одна причина для изменения.

Ну да, красиво звучит, спасибо, конечно... но что с этим теперь делать-то?

Всё дело в том, что нам нужно перестать мыслить "жизнью" в программировании.

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

Но в программировании такое мышление мешает.

Мы создаём не реальную жизнь, а модель поведения, и в этой модели важно отделять роли и зоны ответственности.

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

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

Вот в этом и заключается переход от «жизненного» мышления к архитектурному.

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

Давайте посмотрим, как можно соблюсти этот принцип и привести его в порядок. Итак, давайте будем есть слона по кусочкам. Начнём с подбора монетки.

Мы хотим заложить такую логику, чтобы её можно было легко расширять и менять, не трогая сам класс персонажа.

На самом деле мы можем без труда убрать из класса всю "зашитую" логику.

Пускай персонаж больше не знает, что конкретно произойдёт, когда он подбирает монетку.

Будут ли звуки, спецэффекты или анимация — это уже не его забота.

Пусть персонаж просто «кричит»: «Я подобрал монетку!» — а тот, кому нужно, подхватывает это событие и делает с ним что угодно.

public class Character

{

    // ...

    public event Action? OnCoinPicked;

    // ...

    public void PickCoin()

    {

        Coins += 1;

        OnCoinPicked?.Invoke();

    }

    // ...

}

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

public class SoundPlayer

{

    public const string coinSound = "coin.wav";

    public void PlayCoinSound()

    {

        PlaySound(coinSound);

    }

    private void PlaySound(string file)

    {

        Console.WriteLine($"Воспроизводится звук {file}");

    }

}

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

И вот как это можно было бы использовать в методе Main при настройке:

// Создаём воспроизводитель звука

var soundPlayer = new SoundPlayer();

// Подписываем звуковой плеер на событие подбора монетки

warrior.OnCoinPicked += soundPlayer.PlayCoinSound;

Теперь, когда персонаж подбирает монетку, событие OnCoinPicked срабатывает, и SoundPlayer реагирует на него, проигрывая звук.

Персонаж теперь вообще не знает, что где-то что-то проигрывается. Он просто делает своё дело — подбирает монетку. Всё остальное решают те, кто подписался на событие.

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

А если нам понадобится добавить, например, визуальный эффект при подборе монетки, мы просто добавим к событию ещё один обработчик. И снова — Character останется нетронутым. Например:

public class VisualEffect

{

    public void PlayFlashEffact()

    {

        Console.WriteLine($"Воспроизводится визуальный эффект вспышки");

    }

}

А вот как это будет выглядеть в методе Main:

var warrior = new Character("Warrior", 100, 20, 10);

var soundPlayer = new SoundPlayer();

var visualEffects = new VisualEffect();

warrior.OnCoinPicked += soundPlayer.PlayCoinSound;

warrior.OnCoinPicked += visualEffects.PlayFlashEffact;

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

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

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

Итоговый класс Character — только игровая логика, ничего лишнего:

public class Character

{

    public string Name { get; }

    public int Health { get; private set; }

    public int Damage { get; private set; }

    public int Armor { get; private set; }

    public int Coins { get; private set; }

    public event Action? OnCoinPicked;

    public Character(string name, int health, int damage, int armor)

    {

        Name = name;

        Health = health;

        Damage = damage;

        Armor = armor;

        Coins = 0;

    }

    public void Attack(Character target)

    {

        target.Health -= Damage;

        if (target.Health < 0) target.Health = 0;

    }

    public void Defend()

    {

        Armor += 5;

    }

    public void PickCoin()

    {

        Coins += 1;

        OnCoinPicked?.Invoke();

    }

}

Отдельный класс для звука:

public class SoundPlayer

{

    public const string coinSound = "coin.wav";

    public void PlayCoinSound()

    {

        PlaySound(coinSound);

    }

    private void PlaySound(string file)

    {

        Console.WriteLine($"Воспроизводится звук {file}");

    }

}

Отдельный класс для визуальных эффектов:

public class VisualEffect

{

    public void PlayFlashEffact()

    {

        Console.WriteLine($"Воспроизводится визуальный эффект вспышки");

    }

}

Отдельный класс для логирования:

public class FileLogger

{

    private readonly string _path;

    public FileLogger(string path = "game.log") => _path = path;

    public void Log(string message)

    {

        File.AppendAllText(_path, $"{DateTime.Now:O} {message}{Environment.NewLine}");

    }

}

Отдельный класс для сохранения:

public class FileSaveStore

{

    private readonly string _saveDirectory;

    public FileSaveStore(string directory = ".") => _saveDirectory = directory;

    public void Save(Character character)

    {

        if (character == null)

            return;

        var content = $"{character.Name};{character.Health};{character.Damage};{character.Armor};{character.Coins}";

        File.WriteAllText(Path.Combine(_saveDirectory, $"{character.Name}_save.txt"), content);

    }

}

И вот как всё собирается вместе в методе Main:

    public static void Main()

    {

        var logger = new FileLogger("game.log");

        var saver = new FileSaveStore("save.txt");

        var soundPlayer = new SoundPlayer();

        var visualEffects = new VisualEffect();

        var warrior = new Character("Warrior", 100, 20, 10, 100);

        warrior.OnCoinPicked += soundPlayer.PlayCoinSound;

        warrior.OnCoinPicked += visualEffects.PlayFlashEffact;

        var goblin = new Character("Goblin", 60, 10, 5, 5);

        warrior.Attack(goblin);

        logger.Log($"{warrior.Name} атаковал {goblin.Name} на {warrior.Damage}. HP {goblin.Name}: {goblin.Health}");

        warrior.PickCoin();

        saver.Save(warrior);

Мы не добавили ни единой новой строчки кода — мы просто реорганизовали уже существующий.

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

Давайте проясним несколько моментов. То, что мы сделали, — это лишь один из способов решения задачи.

Мы выбрали вариант с использованием событий, а логирование, например, вынесли прямо в Main.

Но это не единственно возможное решение.

Мы могли пойти другими путями — использовать интерфейсы, делегаты, шаблоны проектирования или другие приёмы.

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

Самое главное — понять смысл, ради которого мы всё это сделали. Не важно, как именно вы реализуете принцип, важно мышление, которое за ним стоит. И если вы научитесь смотреть на код через эту призму — вы уже не просто пишете программы, вы проектируете системы.

Так, погодите, не бывает же такого, что всё идеально? Где тут подвох? Это нормально, что у нас количество классов стало больше?

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

Цена — больше классов.

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

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

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

А что, если разработчики начинают слишком увлекаться этим принципом и плодить миллиард классов?

Да, такое бывает. И это даже имеет своё название — over-engineering или «переинженеринг».

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

Помните, каждый принцип, который мы разбираем**— не закон, а инструмент мышления.**

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

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

Мы понимаем, что всё это может звучать немного сложно.

Вы, возможно, сейчас переживаете: «А как я пойму, где эта грань? Где я уже переусердствую?»

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

Принципы — это не догмы, а инструменты мышления. И чем лучше вы чувствуете код, тем точнее понимаете, где принцип стоит применить, а где можно отпустить.

S — Single Responsibility Principle, или **принцип единой ответственности.**Класс должен иметь только одну причину для изменения.

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

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

Нарушением этого принципа является так называемый божественный объект (God Object) — случай, когда один класс пытается делать всё сразу.  Такой класс быстро становится неуправляемым: любое изменение в одном месте может сломать другое, а тестировать и развивать его становится всё труднее.

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

Следуйте здравому смыслу: разделяйте ответственность там, где это действительно повышает понятность и устойчивость системы.

Подходите осознанно к его применению — и тогда он будет работать на вас, а не против.

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

Божественный объект (God Object) — антипаттерн, при котором один класс выполняет слишком много обязанностей.

Чтобы проверить SRP, задайте вопрос: «Сколько причин для изменения имеет данный класс?»

Каждую ответственность следует выносить в отдельный класс или сервис.

Обсуждение урока

0
Комментарии видны всем. Чтобы участвовать в обсуждении, войдите или зарегистрируйтесь.
Модерация сообщества

Пожаловаться на комментарий

Расскажите модераторам, что именно требует внимания.