itmo_conspects

Объектно-ориентированное проектирование и программирование

Все презентации к лекциям можно найти по ссылке github.com/is-oop-y27

Лекция 1. Основы ООП

В самом начале развития компьютерных наук и программирования код выглядел как-то так:

VAR i
SET i 1
PRINT i
INC i
JIFLS i 10 2

Такой код писался на языке ассемблера, который в те времена был уникален под почти каждый процессор. Язык ассемблера использовал базовые команды, такие как “прибавить два числа” или “прыгнуть по заданному адресу”, которые напрямую переводились в понятные процессору инструкции

Писать на языке ассемблера было очень неудобно, поэтому придумали структурное программирование:

for (var i = 1; i < 10; i++) 
{
    Console.WriteLine(i);
}

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

Поэтому появилась парадигма объектно-ориентированное программирование


Выделяют 3 основных концепции ООП:

Также выделяют композицию, агрегацию и ассоциацию:

Источники: https://www.infoworld.com/article/2243500/exploring-association-aggregation-and-composition-in-oop.html, https://itsobes.com/ru/it/v-chem-raznica-mezhdu-agregaciej-kompoziciej-i-associaciej/, https://www.geeksforgeeks.org/association-composition-aggregation-java/


Таким образом, подведем итоги:

Лекция 2. Проектирование модели

Иммутабельность

Иммутабельность (от immutable) или неизменяемость - свойство данных, не подразумевающее изменения в ООП, которое используется в виде сокрытия изменяемых данных, значения которых не требуют изменений

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

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

public class StudentGroup
{
    public long Id { get; private set; }

    public string Name { get; private set; }

    public List<long> StudentIds { get; set; }

    public void AddStudent(long studentId)
    {
        if (StudentIds.Contains(studentId) is false)
            StudentIds.Add(studentId);
    }
}

Но мы можем сделать эти поля только для чтения при помощи модификатора readonly:

public class StudentGroup {
    private readonly HashSet<long> _studentsIds;
    // так как в группе не бывает двух студентов с одинаковым
    // идентификатором, имеет смысл использовать HashSet

    public StudentGroup(long id, string name)
    {
        Id = id;
        Name = name;
        _studentsIds = new HashSet<long>();
    }

    public long Id { get; }

    public string Name { get; set; }

    public IReadOnlyCollection<long> StudentIds => _studentsIds;

    public void AddStudent(long studentId)
    {
        _studentsIds.Add(studentId);
    }
}

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

Методы Find/Get

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

Тогда соответственно будем именовать методы Get__By__, если метод будет возвращать ошибку, и Find__By__, если метод возвращает null. Пример:

public record Post(long Id, string Title, string Content);

public class User {
    private readonly List<Post> _posts;

    public User(IEnumerable<Post> posts)
    {
        _posts = posts.ToList();
    }

    public Post GetPostById(long postId)
    {
        return _posts.Single(x => x.Id.Equals(postId));
    }

    public Post? FindPostByTitle(string title)
    {
        return _posts.SingleOrDefault(x => x.Title.Equals(title));
    }
}

При этом использование статического полиморфизма (перегрузки методов) вместо методов с суффиксами By__ снижает читаемость и расширяемость:

public Post? FindPost(long postId)
{
    return _posts.Single(x => x.Id.Equals(postId));
}
public Post? FindPost(string title)
{
    return _posts.SingleOrDefault(x => x.Title.Equals(title));
}

Обработка ошибок

При использовании исключений могут возникнуть следующие ситуации:

Утечка абстракции (Leaky Abstraction) - это абстракция, для работы с которой, необходимо иметь знание о деталях ее реализации

Вместо исключений можно возвращать bool, который означает успех операции:

if (long.TryParse("123", out long number))
{
    Console.WriteLine(number);
}

Но, если нам нужно более 2 значений, чтобы передать, что именно пошло не так, можно воспользоваться типом результата (Result type):

public abstract record AddStudentResult
{
    private AddStudentResult() { }
    public sealed record Success : AddStudentResult;
    public sealed record AlreadyMember : AddStudentResult;
    public sealed record StudentLimitReached(int Limit) : AddStudentResult;
}

Здесь описаны 3 результата: успех, студент уже является участником группы и предел группы превышен. Также используются ключевые слова record, позволяющее упростить синтаксис кода, и sealed, гарантирующее, что от типа нельзя наследовать другой тип, обозначающий другой результат

В итоге мы можем возвращать AddStudentResult:

public AddStudentResult AddStudent(long studentId)
{
    if (_studentsIds.Count.Equals(MaxStudentCount))
        return new AddStudentResult.StudentLimitReached(MaxStudentCount);

    if (_studentsIds.Add(studentId) is false)
        return new AddStudentResult.AlreadyMember();

    return new AddStudentResult.Success();
}

И после этого уже проверять наш тип результата:

if (result is AddStudentResult.AlreadyMember)
{
    Console.WriteLine("Студент уже участник группы");
    return;
}
if (result is AddStudentResult.StudentLimitReached err)
{
    var message = $"Нельзя добавить студента, максимум в {err.Limit} студентов уже достигнут";
    Console.WriteLine(message);
    return;
}
if (result is not AddStudentResult.Success)
{
    Console.WriteLine("Операция завершилась с ошибкой");
    return;
}

Console.WriteLine("Студент успешно добавлен");

В итоге это выходит:

Предметно-ориентированное проектирование

Предметно-ориентированное проектирование (Domain driven design, DDD) - проектирование, ориентированное на нужную нам предметную область (или домен), например, область, состоящая из студентов, предметов и преподавателей

При проектировании появляется модель - абстракция, которая упрощённо описывает предметную область, предметы в нем и их взаимоотношений

Рассмотрим паттерны, которые применяются в предметно-ориентированном проектировании:

Лекция 3. Принципы SOLID

Принципы SOLID были сформированы в статье Роберта Мартина “Design Principles and Design Patterns” (статья - *тык*) в 2000 году, которые были предложены в ответ на деградацию программного обеспечения - процесс, в течение которого код становится трудно поддерживаемым

Аббревиатура SOLID состоит из первых букв названий соответствующих принципов:

Принцип единственной ответственности

Принцип единственной ответственности (Single Responsibility Principle, SRP) гласит, что класс должен быть ответственным только за одну сущность и у класса должна быть только одна причина для изменения

Например: делать класс, который создает отчеты одновременно в форматах .xlsx для Excel и .pdf - плохо, так как в них могут быть методы с одинаковыми названиями, но с разной логикой, этот класс будет труднее изменять

public record OperationResult(...);

public class ReportGenerator
{
    public void GenerateExcelReport(OperationResult result)
    {
        ...
    }
    public void GeneratePdfReport(OperationResult result)
    {
        ...
    }
}

Поэтому лучше сделать интерфейс генераторов отчета, от которого наследуются классы генераторов отчетов в Excel и в PDF:

public record OperationResult(...);

public interface IReportGenerator
{
    void GenerateReport(OperationResult result);
}

public class ExcelReportGenerator : IReportGenerator
{
    public void GenerateReport(OperationResult result)
    {
        ...
    }
}
public class PdfReportGenerator : IReportGenerator
{
    public void GenerateReport(OperationResult result)
    {
        ...
    }
}

Преимущества такого подхода:

При несоблюдении принципа единственной ответственности появляются проблемы:

Принцип открытости/закрытости

Принцип открытости/закрытости (Open-Closed Principle, OCP) гласит, что программные сущности должны быть открытыми для расширения и закрытыми для модификации

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

Пример несоблюдения принципа открытости/закрытости:

public enum BinaryOperation
{
    Summation,
    Subtraction,
}

public class BinaryOperand
{
    private readonly int _left;
    private readonly int _right;

    public BinaryOperand(int left, int right)
    {
        _left = left;
        _right = right;
    }

    public int Evaluate(BinaryOperation operation) => operation switch
    {
        BinaryOperation.Summation => _left + _right,
        BinaryOperation.Subtraction => _left - _right,
    };
}

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

public interface IBinaryOperation
{
    int Evaluate(int left, int right);
}

public class Summation : IBinaryOperation
{
    public int Evaluate(int left, int right) => left + right;
}

public class Subtraction : IBinaryOperation
{
    public int Evaluate(int left, int right) => left - right;
}

public sealed class BinaryOperand
{
    private readonly int _left;
    private readonly int _right;

    public BinaryOperand(int left, int right)
    {
        _left = left;
        _right = right;
    }

    public int Evaluate(IBinaryOperation operation)
        => operation.Evaluate(_left, _right);
}

Здесь мы определяем интерфейс операции IBinaryOperation, классы конкретных операторов Summation и Subtraction с их реализацией и передаем объекты классов в класс BinaryOperand

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

Принцип подстановки Лисков (Liskov Substitution Principle, LSP) гласит, что при замене похожих объектов логика программы не должна нарушаться

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

public record Coordinate(int X, int Y);

public class Creature
{
    public void Die()
    {
        Console.WriteLine("Я мертв");
    }
}

public class Bird : Creature
{
    public virtual void FlyTo(Coordinate coordinate)
    {
        Console.WriteLine("Я летаю");
    }
}

public class Penguin : Bird
{
    public override void FlyTo(Coordinate coordinate)
    {
        Die();  // пингвины не летают :(
    }
}

public class Bat : Creature
{
    public void FlyTo(Coordinate coordinate)
    {
        Console.WriteLine("Я летучая мышь и летаю");
    }
}

void StartMigration(IEnumerable<Creature> creatures, Coordinate coordinate)
{
    foreach (var creature in creatures)
    {
        if (creature is Bird bird)
        {
            bird.FlyTo(coordinate);
        }
        if (creature is Bat bat)
        {
            bat.FlyTo(coordinate);
        }
    }
}

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

public record Coordinate(int X, int Y);

public interface ICreature
{
    void Die();
}

public interface IFlyingCreature : ICreature
{
    void FlyTo(Coordinate coordinate);
}

public class CreatureBase : ICreature
{
    public void Die()
    {
        Console.WriteLine("Я мертв");
    }
}

public class Bird : CreatureBase {
    public virtual void FlyTo(Coordinate coordinate)
    {
        Console.WriteLine("Я летаю");
    }
}

public class Penguin : Bird { }

public class Hummingbird : Bird, IFlyingCreature
{
    public void FlyTo(Coordinate coordinate)
    {
        Console.WriteLine("Я колибри и летаю");
    }
}

public class Bat : CreatureBase, IFlyingCreature
{
    public void FlyTo(Coordinate coordinate)
    {
        Console.WriteLine("Я летучая мышь и летаю");
    }
}

void StartMigration(IEnumerable<IFlyingCreature> creatures, Coordinate coordinate)
{
    foreach (var creature in creatures)
    {
        creature.FlyTo(coordinate);
    }
}

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

Принцип разделения интерфейса

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

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

Поэтому лучше делать не так:

public interface ICanAllDevice
{
    void Print();
    void PlayMusic();
    void BakeBread();
}

А так:

public interface IPrinter
{
    void Print();
}

public interface IMusicPlayer
{
    void Play();
}

public interface IBakery
{
    void BakeBread();
}

Такой подход позволяет уменьшить связность зависимостей

Принцип зависимости инверсий

Принцип зависимости инверсий (Dependency Inversion Principle) гласит, что реализации должны зависеть только от интерфейсов, а не от самих реализаций

Например: есть общий сервис NotificationService, отправляющий уведомления, который использует определенный отправитель для электронной почти и для SMS:

public class EmailSender
{
    public void Send(string message) => Console.WriteLine($"Email: {message}");
}

public class SmsSender
{
    public void Send(string message) => Console.WriteLine($"SMS: {message}");
}

public class NotificationService
{
    private readonly EmailSender _emailSender;
    private readonly SmsSender _smsSender;

    public NotificationService()
    {
        _emailSender = new EmailSender();
        _smsSender = new SmsSender();
    }

    public void NotifyByEmail(string message) => _emailSender.Send(message);
    public void NotifyBySms(string message) => _smsSender.Send(message);
}

var service = new NotificationService();
service.NotifyByEmail("Hello!");
service.NotifyBySms("Hello!");

Получаем, что NotificationService зависит от реализаций EmailSender и SmsSender, несмотря на то, что они выполняют одну цель - отправить сообщение методом Send. Поэтому лучше сделать интерфейс IMessageSender, реализации которого принимает NotificationService:

public interface IMessageSender
{
    void Send(string message);
}

public class EmailSender : IMessageSender
{
    public void Send(string message) => Console.WriteLine($"Email: {message}");
}

public class SmsSender : IMessageSender
{
    public void Send(string message) => Console.WriteLine($"SMS: {message}");
}

public class PushSender : IMessageSender
{
    public void Send(string message) => Console.WriteLine($"Push: {message}");
}

public class NotificationService
{
    private readonly IMessageSender _messageSender;

    public NotificationService(IMessageSender messageSender)
    {
        _messageSender = messageSender;
    }

    public void Notify(string message) => _messageSender.Send(message);
}

var emailService = new NotificationService(new EmailSender());
emailService.Notify("Hello by Email");

var smsService = new NotificationService(new SmsSender());
smsService.Notify("Hello by SMS");

var pushService = new NotificationService(new PushSender());
pushService.Notify("Hello by Push");

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

Лекция 4. Шаблоны GRASP

Шаблоны GRASP (General Responsibility Assignment Software Principles, Общие принципы распределения ответственности в ПО) - шаблоны, которые решают задачи назначения ответственности программных компонентов

Шаблоны были описаны Крэгом Ларманом в книге “Применение UML и шаблонов проектирования” в 1997 году. Каждый шаблон помогает решить конкретную задачу, возникающую при проектировании ПО, всего их девять:

  1. Информационный эксперт (Information Expert)
  2. Создатель (Creator)
  3. Контроллер (Controller)
  4. Слабое зацепление (Low Coupling)
  5. Сильная связность (High Cohesion)
  6. Перенаправление (Indirection)
  7. Полиморфизм (Polymorphism)
  8. Устойчивость к изменениям (Protected Variations)
  9. Чистая выдумка (Pure Fabrication)

Информационный эксперт

Информационный эксперт (Information Expert) - это принцип, согласно которому обязанность (то есть действие с данными) назначается классу, который обладает всей необходимой информацией для её выполнения

Приведем пример заказа и создателя чека:

public record OrderItem(int Id, decimal Price, int Quantity);

public class Order
{
    private readonly List<OrderItem> _items;

    public Order()
    {
        _items = new List<OrderItem>();
    }

    public IReadOnlyCollection<OrderItem> Items => _items;
}

public record Receipt(decimal TotalCost, DateTime Timestamp);

public class ReceiptService
{
    public Receipt CalculateReceipt(Order customer)
    {
        var totalCost = customer.Items
            .Sum(order => order.Price * order.Quantity);
        var timestamp = DateTime.Now;
        return new Receipt(totalCost, timestamp);
    }
}

В этом примере при составлении чека мы подсчитываем стоимость позиции заказа order => order.Price * order.Quantity внутри сервиса ReceiptService

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

Исправим пример:

public record OrderItem(int Id, decimal Price, int Quantity)
{
    public decimal Cost => Price * Quantity;
}

public class Order
{
    private readonly List<OrderItem> _items;
    public Order()
    {
        _items = new List<OrderItem>();
    }
    public IReadOnlyCollection<OrderItem> Items => _items;
    public decimal TotalCost => _items.Sum(x => x.Cost);
}

public record Receipt(decimal TotalCost, DateTime Timestamp);

public class ReceiptService
{
    public Receipt CalculateReceipt(Order customer)
    {
        var totalCost = customer.TotalCost;
        var timestamp = DateTime.Now;
        return new Receipt(totalCost, timestamp);
    }
}

Здесь объект заказа Order подсчитывает сумму заказа и формирует объект чека Receipt. Объекты Order и OrderItem выступают в качестве информационных экспертов - каждый знает то, что им нужно для выполнения конкретного метода

Создатель

Создатель (Creator) - шаблон, который решает проблему ответственности за создание используемых объектов. Если объект класса B содержит, активно использует объекты класса A, а также обладает всеми данными для их создания, то объект класса B должен быть создателем объектов класса A

Приведем пример: здесь сервис заказа OrderService создает позицию заказа OrderItem, которая передается в объект заказа Order

public class Order
{
    private readonly List<OrderItem> _items;

    public Order AddItem(OrderItem item)
    {
        _items.Add(item);
        return this;
    }
}

public class OrderService
{
    public Order CreateDefaultOrder()
    {
        var order = new Order()
            .AddItem(new OrderItem(1, 100, 1))
            .AddItem(new OrderItem(2, 1000, 3));
        return order;
    }
}

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

public class Order
{
    private readonly List<OrderItem> _items;

    public Order AddItem(int id, decimal price, int quantity)
    {
        _items.Add(new OrderItem(id, price, quantity));
        return this;
    }
}

public class OrderService
{
    public Order CreateDefaultOrder()
    {
    var order = new Order()
        .AddItem(1, 100, 1)
        .AddItem(2, 1000, 3);
    return order;
    }
}

Недостатки создателя:

Контроллер

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

Сам контроллер не выполняет действия, он их делегирует конкретным исполнителям (их чаще всего называются сервисами)

Различают 3 вида контроллеров:

Слабое зацепление

Зацеплением (Coupling) считается мера зависимости разных модулей друг между другом

Слабое зацепление (Low Coupling) - принцип, согласно которому класс должен иметь как можно меньше зависимостей от других классов. Чем меньше класс знает о внутреннем устройстве других, тем проще его переиспользовать, тестировать и модифицировать

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

Например: есть класс DataProvider, методы которого выводят температуру воздуха в комнате и используемую сборщиком мусора память в чипе микроконтроллера. Логически эти данные никак не связаны, поэтому лучше всего ослабить их зацепление - создать 2 отдельных класса для вывода температуры и для вывода памяти

Сильная связность

Связность (Cohesion) - это мера логической соотнесенности логики в рамках модуля

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

Низкая связность (Low cohesion) возникает, когда класс содержит несвязанные друг с другом обязанности

Например: сделаем класс DataMonitor, который отображает нужную метрику от DataProvider в зависимости от переданного enum MetricType; так как мы работаем с перечислением, то не избежать использования switch, а значит код будет трудно расширять - нарушается принцип открытости/закрытости

В этом случае лучше будет создать интерфейс для DataProvider, реализации которого будут использоваться в DataMonitor

Перенаправление

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

Перенаправление тесно связано с принципами разделения интерфейса и инверсии зависимостей. Принцип перенаправления используется в архитектуре Модель-Представление-Контроллер (Model-View-Controller, MVC): бизнес-логика из модели общается с сущностями представления через посредник-контроллер

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

Полиморфизм

Полиморфизм (Polymorphism) - принцип, согласно которому поведение системы может варьироваться в зависимости от типа объекта, при этом сам код обращается к единому интерфейсу. Полиморфизм позволяет избежать громоздких условных конструкций if/switch и делегирует выбор конкретного поведения самим объектам. Вместо того чтобы проверять тип объекта и вручную выбирать нужное действие, объявляется абстрактный метод или метод интерфейса, в реализации которого в конкретных классах описано желаемое поведение

кря

Устойчивость к изменениям

Устойчивость к изменениям (Protected Variations) - это способность системы оставаться работоспособной и легко модифицируемой при появлении новых требований или изменении существующих. Система с высокой устойчивостью к изменениям требует минимальных правок в уже написанном коде при добавлении новой функциональности

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

Чистая выдумка

Чистая выдумка (Pure fabrication) подразумевает создание выдуманной сущности, которая не входит в моделирование бизнес-логики. Чаще всего это инфраструктурные модули, например, сервис для доступа к базе данных (их называют объектами доступа в данным, Data Access Object)

Такие типы уменьшают зацепление и повышают связность

Лекция 5. Порождающие паттерны

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

Такие объекты следуют порождающим паттернам (Creational Patterns) или порождающим шаблонам

Фабричный метод

Фабричный метод (Factory Method) - метод, который определяет общий интерфейс для создания объектов в родительском классе, позволяя дочерним классам изменять тип создаваемых объектов

Фабричный метод позволяет разделить логику и создания объектов на иерархию типов

Пример: у нас есть объект заказа (Order), хранящий различные позиции (OrderItem); мы хотим передавать этот заказ в калькулятор платежа (PaymentCalculator), применять всякие скидки и купоны, и возвращать готовый платеж наличными (CashPayment) с просчитанным значением:

public record OrderItem(decimal Price, int Amount)
{
    public decimal Cost => Price * Amount;
}

public record Order(IEnumerable<OrderItem> Items)
{
    public decimal TotalCost => Items.Sum(x => x.Cost);
}

public record CashPayment(decimal Amount);

public class PaymentCalculator
{
    public CashPayment Calculate(Order order)
    {
        var totalCost = order.TotalCost;

        // применить скидки и купоны
        ...

        return new CashPayment(totalCost);
    }
}

Здесь мы хотим ввести возможность оплаты не только наличными, но и банковской картой, поэтому создаем объект BankPayment (от интерфейса IPayment) и различные калькуляторы для них

public interface IPayment
{
    decimal Amount { get; }
}

public record CashPayment(decimal Amount) : IPayment;
public record BankPayment(
    decimal Amount,
    string ReceiverAccountId) : IPayment;

public abstract class PaymentCalculator
{
    public IPayment Calculate(Order order)
    {
        var totalCost = order.TotalCost;

        // применить скидки и купоны
        ...

        return CreatePayment(totalCost);
    }

    protected abstract IPayment CreatePayment(decimal amount);
}

public class CashPaymentCalculator : PaymentCalculator
{
    protected override IPayment CreatePayment(decimal amount)
        => new CashPayment(amount);
}

public class BankPaymentCalculator : PaymentCalculator
{
    private readonly string _currentReceiverAccountId;

    public BankPaymentCalculator(string currentReceiverAccountId)
    {
        _currentReceiverAccountId = currentReceiverAccountId;
    }
    protected override IPayment CreatePayment(decimal amount)
    {
        return new BankPayment(amount, _currentReceiverAccountId);
    }
}

Фабричный метод

Здесь же можно выделить в паттерне фабричного метода две сущности:

В данном примере продукт - это реализация интерфейса IPayment, а создатель - метод CreatePayment в абстрактном классе PaymentCalculator. Фабричный метод применяется для переиспользования логики создания на наборе типов. Но при этом фабричный метод считается антипаттерном из-за следующих недостатков:

Абстрактная фабрика

Абстрактная фабрика (или просто фабрика, Abstract Factory) - объект, который позволяет создавать семейства связанных объектов, не привязываясь к конкретным классам создаваемых объектов

Это позволяет вынести логику создания объектов в отдельные типы, объекты-фабрики которых будут ответственны только за это

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

public interface IPaymentFactory
{
    IPayment Create(decimal amount);
}

public class CashPaymentFactory : IPaymentFactory
{
    public IPayment Create(decimal amount) => new CashPayment(amount);
}

public class BankPaymentFactory : IPaymentFactory
{
    private readonly string _currentReceiverAccountId;
    public BankPaymentFactory(string currentReceiverAccountId)
    {
        _currentReceiverAccountId = currentReceiverAccountId;
    }
    public IPayment Create(decimal amount)
    {
        return new BankPayment(amount, _currentReceiverAccountId);
    }
}

public interface IPaymentCalculator
{
    IPayment Calculate(Order order);
}

public class PaymentCalculator : IPaymentCalculator
{
    private readonly IPaymentFactory _paymentFactory;
    public PaymentCalculator(IPaymentFactory paymentFactory)
    {
        _paymentFactory = paymentFactory;
    }
    public IPayment Calculate(Order order)
    {
        var totalCost = order.TotalCost;

        // применить скидки и купоны
        ...

        return _paymentFactory.Create(totalCost);
    }
}

Фабрика

Здесь все фабрики с разными логикам создания нашего Payment реализуются от интерфейса IPaymentFactory. Поэтому мы можем какой-нибудь другой калькулятор FixedPaymentCalculator, который этим пользуется:

public class FixedPaymentCalculator : IPaymentCalculator
{
    private readonly decimal _fixedPrice;
    private readonly IPaymentFactory _paymentFactory;

    public FixedPaymentCalculator(decimal fixedPrice, IPaymentFactory paymentFactory)
    {
        _fixedPrice = fixedPrice;
        _paymentFactory = paymentFactory;
    }
    public IPayment Calculate(Order order)
    {
        var totalCost = order.Items.Sum(item =>_fixedPrice * item.Amount);

        // применить скидки и купоны
        ...

        return _paymentFactory.Create(totalCost);
    }
}

При этом заметить следующие преимущества у абстрактной фабрики:

Строитель

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

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

Разделяют 2 вида строителей:

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

class Service
{
    public Service(IDependency1? one, IDependency2 two, IDependency3 three)   { ... }
    ...
}
internal interface IDependency3 { ... }
internal interface IDependency2 { ... }
internal interface IDependency1 { ... }

class ServiceBuilder
{
    private IDependency1? _one;
    private IDependency2? _two;
    private IDependency3? _three;
    public ServiceBuilder()
    {
        _one = null;
        _two = new Dependency2();
        _three = new Dependency3();
    }
    public ServiceBuilder WithOne(IDependency1 one) { ... }
    public ServiceBuilder WithTwo(IDependency2 two) { ... }
    public ServiceBuilder WithThree(IDependency3 three) { ... }
    public Service Build()
    {
        return new Service(
            _one,
            _two ?? throw new InvalidOperationException(),
            _three ?? throw new InvalidOperationException());
    }
}

Строитель

С помощью строителя с сохранением состояния мы можем принимать аргументы через методы строителя. В итоге вместо такого вызова конструктора:

var model = new Model(arg1, arg2, arg3, arg4, arg5);

мы получаем:

var builder = new ModelBuilder()
    .AddArg1(arg1)
    .AddArg2(arg2)
    .AddArg3(arg3)
    .AddArg4(arg4)
    .AddArg5(arg5);

var model = new builder.Build();

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

public class Model
{
    private Model(IReadOnlyCollection<Data> data, ...)
    {
        Data = data;
        ...
    }

    public IReadOnlyCollection<Data> Data { get; }

    public static ModelBuilder Builder => new ModelBuilder();

    public class ModelBuilder
    {
        private readonly List<Data> _data;
        ...

        public ModelBuilder AddData(Data data)
        {
            _data.Add(data);
            return this;
        }
        public Model Build()
        {
            return new Model(_data, ...);
        }
    }
}

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

public interface IModelBuilder {
    ...

    Model Build();
}

public class ConcreteBuilderA : IModelBuilder
{
    ...

    public Model Build() { ... }
}
public class ConcreteBuilderB : IModelBuilder
{
    ...

    public Model Build() { ... }
}

Заметим, что строитель - это инфраструктурный код, неприоритетный при проектировании

Здесь же можем к строителю внедрить директора:

public static class BuilderDirector
{
    public static Builder DirectNumeric(
        this Builder builder,
        int count)
    {
        var enumerable = Enumerable.Range(0, count);
        foreach (var i in enumerable)
        {
            var data = new DataA(i);
            builder = builder.WithDataA(data);
        }
        return builder;
    }
}

public interface IBuilderDirector
{
    Builder Direct(Builder builder);
}

public class InstanceDirector : IBuilderDirector
{
    private readonly int _size;
    private IEnumerable<Model> _prototypes;
    ...
    public Builder Direct(Builder builder) { ... }
}

Директор определяет порядок вызова строительных шагов для производства той или иной конфигурации продуктов

Или же можно сделать цепочку из интерфейсов для получения данных:

public interface IAddressBuilder
{
    ISubjectBuilder WithAddress(string address);
}
public interface ISubjectBuilder
{
    IEmailBuilder WithSubject(string subject);
}
public interface IEmailBuilder
{
    IEmailBuilder WithBody(string body);
    Email Build();
}
public class Email
{
    public static IAddressBuilder Builder => new EmailBuilder();

    public class EmailBuilder : IAddressBuilder, ISubjectBuilder, IEmailBuilder {
        ...
    }
}

var email = Email.Builder
 .WithAddress("aboba@email.com")
 .WithSubject("subject")
 .Build();

Здесь мы принуждаем к порядку сбора данных: адрес -> тема -> тело письма

Прототип

Прототип (Prototype) позволяет копировать объекты, не вдаваясь в подробности их реализации, что упрощает копирование объекта. Почему не пользоваться просто конструктором:

Примитивный прототип может быть таким:

public class Prototype
{
    private readonly IReadOnlyCollection<int> _relatedEntityIds;
    public Prototype(IReadOnlyCollection<int> relatedEntityIds)
    {
        _relatedEntityIds = relatedEntityIds;
    }
    public Prototype Clone()
    {
        return new Prototype(_relatedEntityIds);
    }
}

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

public class WrappedValue
{
    public int Value { get; set; }
    public WrappedValue Clone()
        => new WrappedValue{ Value = Value };
}
public class DeepCopyPrototype
{
    private readonly List<WrappedValue> _values;
    public DeepCopyPrototype(List<WrappedValue> values)
    {
        _values = values;
    }
    public DeepCopyPrototype Clone()
    {
        List<WrappedValue> values = _values.Select(x => x.Clone()).ToList();
        return new DeepCopyPrototype(values);
    }
}

Теперь внедрим прототип в иерархию классов:

public abstract class Prototype
{
    public void DoSomeStuff() { ... }
    public abstract Prototype Clone();
}
public class ClassPrototype : Prototype
{
    public void DoOtherStuff() { ... }
    public override Prototype Clone() => new ClassPrototype();
}

Пример использования может быть таким:

public class Scenario
{
    public static Prototype CloneAndDoSomeStuff(Prototype prototype)
    {
        var clone = prototype.Clone();
        clone.DoSomeStuff();
        return clone;
    }
    public static void TopLevelScenario()
    {
        var prototype = new ClassPrototype();
        Prototype clone = CloneAndDoSomeStuff(prototype);

        clone.DoOtherStuff();
    }
}

Здесь строка clone.DoOtherStuff(); вызовется с ошибкой, так как у базового класса нет метода DoOtherStuff(). Попробуем сделать прототип при помощи интерфейса:

public interface IPrototype
{
    IPrototype Clone();
    void DoSomeStuff();
}
public class InterfacePrototype : IPrototype
{
    IPrototype IPrototype.Clone() => Clone();

    public InterfacePrototype Clone() => new InterfacePrototype();
    public void DoSomeStuff() { ... }
    public void DoOtherStuff() { ... }
}

И точно такой же сценарий работы:

public class Scenario
{
    public static IPrototype CloneAndDoSomeStuff(IPrototype prototype)
    {
        var clone = prototype.Clone();
        clone.DoSomeStuff();

        return clone;
    }
    public static void TopLevelScenario()
    {
        var prototype = new InterfacePrototype();
        IPrototype clone = CloneAndDoSomeStuff(prototype);

        clone.DoOtherStuff();
    }
}

Здесь опять же в clone.DoOtherStuff(); возникнет ошибка - мы ничего не знаем про класс-наследник. В этом случае мы можем преобразовать тип интерфейса к известному нами типу:

InterfacePrototype clone = (InterfacePrototype)CloneAndDoSomeStuff(prototype);

Но решим это при помощи рекурсивного параметр-типа - параметр-типа, ссылающегося на себя. Реализуем это при помощи дженериков в C#

public interface IPrototype<T> where T : IPrototype<T>
{
    T Clone();
    void DoSomeStuff();
}
public class Prototype : IPrototype<Prototype>
{
    public Prototype Clone() => new Prototype();
    public void DoSomeStuff() { ... }
    public void DoOtherStuff() { ... }
}

Тот же самый сценарий:

public class Scenario
{
    public static T CloneAndDoSomeStuff<T>(T prototype) where T : IPrototype<T>
    {
        var clone = prototype.Clone();
        clone.DoSomeStuff();

        return clone;
    }
    public static void TopLevelScenario()
    {
        var prototype = new Prototype();
        Prototype clone = CloneAndDoSomeStuff(prototype);

        clone.DoOtherStuff();
    }
}

И здесь метод Clone возвращает именно тип наследника

Одиночка

Одиночка (или синглтон, Singleton) - объект, для которого мы гарантируем, что одновременно не может существовать больше одного экземпляра. Синглтоном может быть, например, глобальный кеш. Пример:

public class Singleton
{
    private static readonly object _lock = new();
    private static Singleton? _instance;
    private Singleton() { }
    public static Singleton Instance
    {
        get
        {
            if (_instance is not null)
                return _instance;
            lock (_lock)   // с помощью lock гарантируем, что
            {              // код ниже выполнится только в одном потоке
                if (_instance is not null)
                    return _instance;

                return _instance = new Singleton();
            }
        }
    }
}

Также существует реализация через встроенный объект Lazy:

public class Singleton
{
    private static readonly Lazy<Singleton> _instance;
    static Singleton()
    {
        _instance = new Lazy<Singleton>(()
            => new Singleton(), LazyThreadSafetyMode.ExecutionAndPublication);
    }
    private Singleton() { }
    public static Singleton Instance => _instance.Value;
}

Одиночка считается антипаттерном по этим причинам:

Лекция 6. Воркшоп 2

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

Код с воркшопа можно посмотреть здесь: https://github.com/is-oop-y27/workshop-2/tree/master-12-10-2024

Перед нами стоят такие требования:

Сразу же выделим сущности “Текст” (со строкой и с форматированием), “Параграф” (с заголовком, несколькими “Текстами” и с опциональным заключением) и “Статья” (с названием)

Лучше всего начинать с абстракций, которые меньше всего зависят от других, поэтому создадим общий интерфейс IRenderable для объектов, которые мы будем отображать в консоль, с методом Render, возвращающий строку

Теперь сделаем интерфейс для отрисовщиков IDrawer, принимающий реализацию интерфейса IRenderable. Сделаем реализацию ConsoleDrawer, который просто вызывает метод Render и выводит строку в консоль классическим методом.

Теперь сделаем интерфейс IText с рекурсивным дженериком:

public interface IText<T> : IRenderable
    where T : IText<T>
{
    T Clone();

    T AddModifier(IRenderableModifier modifier);
}

Рекурсивный дженерик нам нужен, чтобы возвращать копию с исходным типов (подробнее об этом в паттерне “Прототип”)

Создадим реализацию Text

Так как текст мы хотим форматировать, сделаем интерфейс для модификатора IRenderableModifier с методом Modify, который принимает строку и возвращает ее отформатированный вариант

Форматировать текст в консоль будем при помощи библиотеки Crayon, сделаем модификатор для цвета ColorModifier и модификатор для жирного текста BoldModifier

Теперь дополним наш класс Text до такой имплементации:

public class Text : IText<Text>
{
    private readonly List<IRenderableModifier> _modifiers;

    public Text(string value)
    {
        Value = value;
        _modifiers = [];
    }

    private Text(string value, IEnumerable<IRenderableModifier> modifiers)
    {
        Value = value;
        _modifiers = modifiers.ToList();
    }

    public string Value { get; set; }

    public Text Clone()
        => new(Value, _modifiers);

    public string Render()
    {
        return _modifiers.Aggregate(
            Value,
            (v, m) => m.Modify(v));
    }

    public Text AddModifier(IRenderableModifier modifier)
    {
        _modifiers.Add(modifier);
        return this;
    }
}

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

Сделаем интерфейс для параграфа IParagraph и саму реализацию Paragraph. В ней довольно тривиально реализовываем конструктор и метод Render

Также сделаем другую реализацию/обертку StyledParagraph для применения модификаторов на весь параграф

Объект параграф довольно-таки громоздкий - 3 атрибута, один из которых список, поэтому сделаем для него строитель. Наш строитель будет состоять из 2 интерфейсов: IParagraphHeaderSelector и IParagraphBuilder (лучшей практикой является разделение этих интерфейсов на два отдельных файла). Таким образом мы отделили метод WithHeader от AddSection и WithFooter

Сделаем абстрактную реализацию ParagraphBuilderBase - в нем через методы мы собираем данные. Аналогично сделаем реализацию строителя обычного параграфа DefaultParagraphBuilder и стилизованного параграфа StyledParagraphBuilder, который передает модификаторы в StyledParagraph

Теперь самое вкусное: сделаем интерфейс фабрики строителя параграфа с методом Create, возвращающим нужный строитель, но в виде интерфейса IParagraphHeaderSelector, чтобы принудить пользователя ввести обязательно заголовок параграфа и не дает собрать параграф

Теперь накатим реализации обычной фабрики DefaultParagraphBuilderFactory и стилизованной фабрики StyledParagraphBuilderFactory - в ней мы передаем модификатор, в последствии фабрика передает его в строитель:

public class StyledParagraphBuilderFactory : IParagraphBuilderFactory
{
    private readonly IRenderableModifier _modifier;

    public StyledParagraphBuilderFactory(IRenderableModifier modifier)
    {
        _modifier = modifier;
    }

    public IParagraphHeaderSelector Create()
    {
        return new StyledParagraphBuilder(_modifier);
    }
}

Перейдем к созданию статей: сделаем интерфейс IArticle, который наследуется от IRenderable и IArticleBuilderDirector - директора строителя. Интерфейс директора строителя IArticleBuilderDirector дает нам метод Direct принимающий и возвращающий строитель статьи (о нем позже)

Создадим интерфейс строителя с методами WithName, AddParagraph, WithAuthor и Build и тривиальную реализацию ArticleBuilder

Сделаем реализацию статьи Article с уже понятными конструктором и методом Render, и методом Direct, который берет созданный извне строитель, передает ему данные текущей статьи и возвращает обратно - таким образом делает копию статьи в строителе:

    public IArticleBuilder Direct(IArticleBuilder builder)
    {
        builder = builder.WithName(_name);

        if (_author is not null)
        {
            builder = builder.WithAuthor(_author);
        }

        builder = _paragraphs.Aggregate(
            builder,
            (b, p) => b.AddParagraph(p));

        return builder;
    }

Лекция 7. Структурные паттерны

На этой лекции разберем структурные паттерны (Structural Patterns), которые помогают в проектировании проектной модели

Адаптер

Адаптер (Adapter) - промежуточный тип, использующий объект одного типа, для реализации интерфейса другого типа

Для применения паттерна “Адаптер” обычно есть цель (Target), которая представляет целевой интерфейс, через который мы хотим взаимодействовать с объектом, изначально его не реализующий. Далее должен быть адаптируемый тип (Adaptee) с другим интерфейсом, который мы хотим использовать через целевой интерфейс с помощью адаптера - обертки, реализующей целевой интерфейс, содержащей объект адаптируемого типа и перенаправляющей в него вызовы поведений целевого интерфейса

Яркий пример применения адаптера - это различие форматов розетки и вилки во всем мире. Так, например, британский стандарт использует три прямоугольных штыря - один для фазы, другой для нейтрали, а третий для заземления. Чтобы подключить британскую вилку (она является адаптируемым типом) к европейской розетке, которая принимает европейские вилки, состоящей из двух цилиндрических штырей (это целевой интерфейс), нужен адаптер - кусок пластика с металлическим контактами, которые изогнуты так, чтобы подключать британскую вилку к европейской розетке

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

public class PostgresLogStorage
{
    public void Save(
        string message,
        DateTime timeStamp,
        int severity)
    {
        ...
    }
}
public class ElasticSearchLogStorage
{
    public void Save(ElasticLogMessage message)
    {
        ...
    }
}
public interface ILogStorage
{
    void Save(LogMessage message);
}

public class PostgresLogStorageAdapter : ILogStorage
{
    private readonly PostgresLogStorage _storage;
    public void Save(LogMessage message)
    {
        _storage.Save(
            message.Message,
            message.DateTime,
            message.Severity.AsInteger());
    }
}
public class ElasticLogStorageAdapter : ILogStorage
{
    private readonly ElasticSearchLogStorage _storage;
    public void Save(LogMessage message)
    {
        _storage.Save(message.AsElasticLogMessage());
    }
}

Адаптер

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

Помимо этого адаптеры это:

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

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

Таким образом, мы получаем два этапа, которые легче тестировать по отдельности

Мост

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

Мост (Bridge) - это объект, который разделяет один или несколько классов на две отдельные иерархии, позволяя изменять их независимо друг от друга

Пусть у нас будет сложное устройство “Телевизор”:

public interface IDevice
{
    public bool IsEnabled { get; set; }
    public int Channel { get; set; }
    public int Volume { get; set; }
}

И простое устройство “Пульт управления”:

public interface IControl
{
    void ToggleEnabled();
    void ChannelForward();
    void ChannelBackward();
    void VolumeUp();
    void VolumeDown();
}

Мост между ними будет выглядеть так:

public class Control : IControl
{
    private readonly IDevice _device;
    public void ToggleEnabled()
        => _device.IsEnabled = !_device.IsEnabled;
    public void ChannelForward()
        => _device.Channel += 1;
    public void ChannelBackward()
        => _device.Channel -= 1;
    public void VolumeUp()
        => _device.Volume += 10;
    public void VolumeDown()
        => _device.Volume -= 10;
}

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

Мост

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

Компоновщик

Компоновщик (Composite) - это представление древовидной структуры объектов в виде одного композитного объекта

Допустим, что у нас есть куча объектов, реализующих один интерфейс, и мы хотим сделать со всеми ними какое-то действие:

public interface IGraphicComponent
{
    void MoveBy(int x, int y);
    void Draw();
}
public class Circle : IGraphicComponent
{
    public void MoveBy(int x, int y) { ... }
    public void Draw() { ... }
}
public class Line : IGraphicComponent
{
    public void MoveBy(int x, int y) { ... }
    public void Draw() { ... }
}

Сделаем из них объект-_компоновщик_ GraphicComponentGroup, который циклом проходится и выполняет это действие у всех объектов:

public class GraphicComponentGroup : IGraphicComponent
{
    private readonly IReadOnlyCollection<IGraphicComponent> _components;
    public void MoveBy(int x, int y)
    {
        foreach (var component in _components)
            component.MoveBy(x, y);
    }
    public void Draw()
    {
        foreach (var component in _components)
            component.Draw();
    }
}

Компоновщик

Декоратор

Декоратор (Decorator) - это тип-обёртка над объектом абстракции, которую он реализует, добавляя к поведению объекта новую логику

Допустим у нас есть абстракция какого-то абстрактного сервиса:

public interface IService
{
    void DoStuff(DoStuffArgs args);
}
public class Service : IService
{
    public void DoStuff(DoStuffArgs args) { }
}

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

public class LoggingServiceDecorator : IService
{
    private readonly IService _decoratee;
    private readonly ILogger _logger;
    public void DoStuff(DoStuffArgs args)
    {
        _logger.Log(ArgsToLogMessage(args));
        _decoratee.DoStuff(args);
    }
    private static string ArgsToLogMessage(DoStuffArgs args) { ... }
}

Декоратор

Заместитель

Заместитель (или прокси, Proxy) - тип-обёртка, реализующий логику контроля доступа к объекту, реализующему абстракцию, которую реализует он сам

Возьмем для примера обычный сервис, реализующий интерфейс:

public interface IService
{
    void DoOperation(OperationArgs args);
}
public class Service : IService
{
    public void DoOperation(OperationArgs args) { }
}

Рассмотрим несколько видов заместителей:

Заместитель

Как можно заметить, прокси очень подозрительно похож на декоратор, однако:

Фасад

Фасад (Facade) - оркестрация одной или несколько сложных операций в каком-либо типе

Фасад рассматривался как контроллер в шаблонах GRASP. Фасад лучше не использовать для описания бизнес-логики, потому что существуют:

Но фасад может быть полезен в модели “запрос-ответ”, например, как объектная обертка вызовов API (Application Programming Interface, интерфейс программирования приложения)

Фасад

Легковес

Легковес (Flyweight) - декомпозиция объектов, выделенные тяжелых и повторяющихся данных в отдельные модели для дальнейшего переиспользования

При помощи легковеса мы можем отделить тяжелый объект, чтобы каждый раз пользоваться им по ссылке и не создавать новый

Допустим, что есть программа, моделирующая поведение частиц Particle, которые имеют определенные данные:

public record Particle(int X, int Y, byte[] Model);

public class ParticleFactory
{
    private readonly IAssetLoader _assetLoader;
    public Particle Create(string modelName)
    {
        var model = _assetLoader.Load(modelName);
        return new Particle(0, 0, model);
    }
}

Вместо создания множества копий значения Model можно всего лишь хранить ссылки на них в кеше легковеса ParticleFactory:

public record ModelData(byte[] Value);

public record Particle(int X, int Y, ModelData Model);

public class ParticleFactory
{
    private readonly IAssetLoader _assetLoader;
    private readonly Dictionary<string, ModelData> _cache;
    public Particle Create(string modelName)
    {
        var model = _cache.TryGetValue(modelName, out var data)
            ? data
            : _cache[modelName] =
                new ModelData(_assetLoader.Load(modelName));
        return new Particle(0, 0, model);
    }
}

Легковес

Лекция 8. Воркшоп 3

На этом воркшопе будут примеры использования структурных паттернов. Код воркшопа: https://github.com/is-oop-y27/workshop-3

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

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

Заметим, что любое выражение можно представить в виде бинарного дерева.

Сделаем в проекте второго воркшопа папку Expressions, где будет независимая от моделей статей реализация выражений. В ней создадим интерфейс IExpression с двумя методами:

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

Операнды выражения в нашей модели являются либо константными значениями, либо переменными. Для констант сделаем класс ConstantExpression, реализующий IExpressionValue, наследующийся от IExpression. У константного значения сделаем свойство Value

Для VariableExpression, реализующий IExpression, сделаем метод Evalute, который достает значение из словаря контекста

Чтобы применять арифметические операторы к выражениям, сделаем BinaryOperatorExpression, принимающий два IExpression и IBinaryOperator. Реализации IBinaryOperator будут хранить в себе логику обработки двух выражений. По сути IBinaryOperator является поведенческим паттерном “Стратегия”. При вычислении BinaryOperatorExpression будет пытаться вычислить значения у своих детей; при успехе он применит операцию к полученным значениям.

Теперь сделаем Articles.Extensions для того, чтобы связать модели статей и матвыражений

Сделаем класс ExpressionRenderable - он будет мостом между интерфейсами IRenderable и IExpression. При вызове Render объект будет возвращать текстовое представление выражения.

Также сделаем декоратор StyledExpressionDecorator - он реализует интерфейс IExpression и содержит в себе модификатор для текста, при вызове метода Format он будет возвращать форматированный текст. Аналогично сделаем для интерфейса IExpressionValue

Лекция 9. Поведенческие паттерны. Воркшоп 4

На этом воркшопе будут рассматриваться поведенческие паттерны. Рассматриваемый код доступен в этом репозитории: https://github.com/is-oop-y27/workshop-4

Шаблонный метод

Шаблонный метод (Template Method) - паттерн, позволяющий подклассам переопределять поведение, не меняя сигнатуры метода

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

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

public interface IEmployeeEvaluator
{
    Employee FindBestEmployee(IEnumerable<RatedEmployee> employees);
}

И абстрактный класс EmployeeEvaluatorBase:

public abstract class EmployeeEvaluatorBase : IEmployeeEvaluator
{
    public Employee FindBestEmployee(IEnumerable<RatedEmployee> employees)
    {
        IEnumerable<RatedEmployee> sorted = Sort(employees);
        return sorted.First().Employee;
    }

    protected abstract IEnumerable<RatedEmployee> Sort(
        IEnumerable<RatedEmployee> employees);
}

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

public class TaskEmployeeEvaluator : EmployeeEvaluatorBase
{
    protected override IEnumerable<RatedEmployee> Sort(
        IEnumerable<RatedEmployee> employees)
    {
        return employees.OrderByDescending(x => x.Rating.TaskCompletedCount);
    }
}

public class HoursEmployeeEvaluator : EmployeeEvaluatorBase
{
    protected override IEnumerable<RatedEmployee> Sort(IEnumerable<RatedEmployee> employees)
    {
        return employees.OrderByDescending(x => x.Rating.HoursWorked);
    }
}

Как можем заметить, шаблонный метод подозрительно похож на фабричный метод, у него такие же недостатки:

При этом фабричный метод - паттерн порождающий, а шаблонный - поведенческий. Полный код примера с шаблонным методом - https://github.com/is-oop-y27/workshop-4/tree/master/src/1_TemplateMethod

Стратегия

Стратегия (Strategy) - паттерн, в котором схожие алгоритмы помещаются в классы, которые можно взаимозаменять во время исполнения программы

Проблема та же, что и с шаблонным методом - параметризуем задачу; отличие в том, что в шаблонном методе используем наследование, а в стратегии - композицию

Возьмем тот же пример: сортировка сотрудников. Здесь вынесем метод Sort в классы EmployeeSorter, которые будем передавать в EmployeeEvaluator:

var sorter = new TaskEmployeeSorter();

var evaluator = new EmployeeEvaluator(sorter);

Employee bestEmployee = evaluator.FindBestEmployee(ratedEmployees);

Помимо этого этот sorter можно использовать в двух или более местах.

В целом, стратегией можно называть любую выделенную абстракцию. Код стратегии: https://github.com/is-oop-y27/workshop-4/tree/master/src/2_Strategy

Цепочка обязанностей

Цепочка обязанностей (Chain of responsibility) - паттерн, позволяющий передавать запрос по цепи через обработчики, которые решают, что с запросом делать

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

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

public class OutputRunner : IOutputRunner
{
    private readonly IParameterHandler _handler;

    public OutputRunner(IParameterHandler handler)
    {
        _handler = handler;
    }

    public void Run(IEnumerable<string> args)
    {
        using IEnumerator<string> request = args.GetEnumerator();
        ITextModifier? modifier = null;

        while (request.MoveNext())
        {
            ITextModifier? nextModifier = _handler.Handle(request);

            if (nextModifier is not null)
            {
                modifier = new AggregateModifier(modifier, nextModifier);
            }
        }

        var text = "Hello world!";
        text = modifier?.Modify(text) ?? text;

        Console.WriteLine(text);
    }
}

В итоге, каждый обработчик ответственен за одну какую-то штуку. Код пример цепочки: https://github.com/is-oop-y27/workshop-4/tree/master/src/3_ResponsibilityChain

Наблюдатель

Наблюдатель (Observer) - паттерн, который позволяет наблюдать одним объектам за события, которые генерирует другой объект

Чаще всего есть есть сущность, которая производит какие-то события (такой объект называют издателем), и сущности, которые хотят отслеживать эти события (такие объекты называют наблюдателями или подписчиками)

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

public interface IChatObserver
{
    void OnChatMessageReceived(ChatUserMessage message);
}

public class Chat
{
    private readonly List<IChatObserver> _observers = [];

    public Chat(long id, string name)
    {
        Id = id;
        Name = name;
    }

    public long Id { get; }

    public string Name { get; }

    public void SendMessage(UserMessage message)
    {
        foreach (IChatObserver observer in _observers)
        {
            observer.OnChatMessageReceived(new ChatUserMessage(
                this,
                message));
        }
    }

    public void AddObserver(IChatObserver observer)
    {
        _observers.Add(observer);
    }
}

Обычно перед началом отправки событий подписчики должны изъявить желание получать события с помощью вызова метода AddObserver

Код издателя-подписчика: https://github.com/is-oop-y27/workshop-4/tree/master/src/4_Observer

Команда

Команда (Command) - объект, хранящий поведение, что позволяет его хранить, передавать, ставить в очередь или производить отмену действия

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

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

Пример использования команд в веб-приложении: https://github.com/is-oop-y27/workshop-4/tree/master/src/5_Command

Посетитель

Посетитель (или визитор, Visitor) - паттерн, позволяющий добавлять в программу новые операции, не изменяя изначальные классы объектов

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

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

public interface IFileSystemComponentVisitor
{
    void Visit(FileFileSystemComponent component);

    void Visit(DirectoryFileSystemComponent component);
}

В реализации ConsoleVisitor сделаем вывод имени файла/директории

А в самих объектах, представляющих файлы и директории, сделаем метод Accept(IFileSystemComponentVisitor visitor):

public void Accept(IFileSystemComponentVisitor visitor)
{
    visitor.Visit(this);
}

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

var factory = new FileSystemComponentFactory();
IFileSystemComponent component = factory.Create("sample_folder");

var visitor = new ConsoleVisitor();

component.Accept(visitor);

Код примера: https://github.com/is-oop-y27/workshop-4/blob/master/src/6_Visitor

Снимок

Снимок (Snapshot или Memento) - паттерн, позволяющий сохранить состояние объектов

В паттерне “Снимок” есть 2 сущности:

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

var caretaker = new TextFieldHistory(new TextField());

caretaker.UpdateValue("1");
TextFieldSnapshot snapshot = caretaker.UpdateValue("2");

Console.WriteLine(string.Join("\n", caretaker.History.Select(x => x.ToString())));

Console.WriteLine(caretaker.Value);
caretaker.Restore(snapshot);
Console.WriteLine(caretaker.Value);

Здесь смотритель хранит в себе снимки инициатора и может изменять его через свой метод, возвращающий снимок. Код из примера: https://github.com/is-oop-y27/workshop-4/tree/master/src/7_Snapshot

Но в каком-то случае использования, если изменяемый объект тяжелый, а изменения маленькие, то лучше использовать команды

Состояние

Состояние (State) - паттерн, позволяющий объектам менять поведение в зависимости от своего состояния

Паттерн “Состояние” просто описывает конечная машина состояний (finite state machine) - объекты представляются как состояния, а переходы между ними как методы, возвращающие тип результат, показывающий, есть такой переход или нет

Машина состояний на примере состояний лабораторной работы:

var submission = new Submission(new ActiveSubmissionStateHandler());

submission.Complete();
submission.Ban();

SubmissionActionResult result = submission.Complete();
Console.WriteLine(result);

Код примера: https://github.com/is-oop-y27/workshop-4/blob/master/src/8_State

Итератор

Итератор (Iterator) - паттерн, позволяющий обходить элементы составных объектов, не раскрывая их внутреннего представления

В C# итерируемые объекты реализованы через интерфейс IEnumerable и метод GetEnumerator, который возвращает итератор - реализацию интерфейса IEnumerator:

Применяя это к примеру файловой системы выше, с помощью методов расширения:

public static class FileSystemComponentExtensions
{
    public static IEnumerator<IFileSystemComponent> EnumerateBreadth(this IFileSystemComponent component)
        => new FileSystemBreadthIterator(component);

    public static IEnumerator<IFileSystemComponent> EnumerateDepth(this IFileSystemComponent component)
        => new FileSystemDepthIterator(component);
}

А реализации итераторов (в данном случае сделаем итераторы обходов в глубину и в ширину) мы можем сделать так:

var factory = new FileSystemComponentFactory();
IFileSystemComponent component = factory.Create("sample_folder");

using IEnumerator<IFileSystemComponent> breadthIterator = component.EnumerateBreadth();

while (breadthIterator.MoveNext())
{
    Console.WriteLine(breadthIterator.Current.Name);
}

Код примера итератора: https://github.com/is-oop-y27/workshop-4/tree/master/src/9_Iterator

Лекция 10. Многослойная архитектура

Архитектура приложения - это способ структурирования программных компонентов приложения для управления сложностью

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

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

Архитектура “Модель-Представление-Контроллер”

Простым решением является архитектура “Модель-Представление-Контроллер” (Model-View-Controller, MVC), состоящая из 3 компонентов:

Вместо контроллера может быть презентатор (тогда получается MVP, Model-View-Presenter) или другой компонент, выполняющий больше или меньше функций

Архитектура MVC соответствует принципу единственной ответственности и имеет высокую связность, но также имеет высокое зацепление: чтобы изменить, например, модуль представления, нужно изменить контроллер

MVC

Трехслойная архитектура

Трехслойная архитектура (или трехуровневая, Three-tier architecture) делит программные модули на три слоя:

В трехслойной архитектуре разделяют 2 типа моделей данных:

Трехслойная архитектура

Гексагональная архитектура

В гексагональной (или шестиугольной) архитектуре (Hexagonal architecture) у компонента бизнес-логики есть порты двух типов:

Гексагональная архитектура позволяет сделать бизнес-логику независимой от вспомогательных реализаций

Гексагональная архитектура

Важно подметить, что архитектура не имеет ничего общего с шестиугольниками

Луковая архитектура

Луковая (также “чистая”, Clean Architecture) архитектура основывается на гексагональной, но в отличии от нее компонент с бизнес-логикой разделяется на части с доменными сервисами (Domain Services) и доменными моделями (Domain Model)

Архитектура выделяет слой приложения - связующее звено между инфраструктурными абстракциями и доменом

Луковая архитектура

Лекция 11. Воркшоп 5

На пятом воркшопе разбирался код абстрактного магазина, спроектированного по трехслойной архитектуре. Код воркшопа: https://github.com/is-oop-y27/workshop-5/

Проект с воркшопа использует анемичную модель данных. Если взглянуть на структуру проекта, то мы увидим это:

📂src
    📂Application
        📂Workshop5.Application
            📂Extensions
                📄ServiceCollectionExtensions.cs
            📂Shops
                📄ShopService.cs
            📂Users
                📄CurrentUserManager.cs
                📄UserService.cs
            📄Workshop5.Application.csproj
        📂Workshop5.Application.Abstractions
            📂Repositories
                📄IShopRepository.cs
                📄IUserRepository.cs
            📄Workshop5.Application.Abstractions.csproj
        📂Workshop5.Application.Contracts
            📂Shops
                📄IShopService.cs
            📂Users
                📄ICurrentUserService.cs
                📄IUserService.cs
                📄LoginResult.cs
            📄Workshop5.Application.Contracts.csproj
        📂Workshop5.Application.Models
            📂Products
                📄Product.cs
                📄ProductCategory.cs
            📂Shops
                📄Shop.cs
            📂Users
                📄User.cs
                📄UserRole.cs
            📄Workshop5.Application.Models.csproj
    📂Infrastructure
        📂Workshop5.Infrastructure.DataAccess
            📂Extensions
                📄ServiceCollectionExtensions.cs
                📄ServiceScopeExtensions.cs
            📂Migrations
                📄01_Initial.cs
            📂Plugins
                📄MappingPlugin.cs
            📂Repositories
                📄ShopRepository.cs
                📄UserRepository.cs
            📄Workshop5.Infrastructure.DataAccess.csproj
    📂Presentation
        📂Workshop5.Presentation.Console
            📄ChainLinkBase.cs
            📂Extensions
                📄ServiceCollectionExtensions.cs
            📄IChainLink.cs
            📄IScenario.cs
            📄IScenarioProvider.cs
            📄ScenarioRunner.cs
            📂Scenarios
                📂AddShopProduct
                    📄AddShopProductScenario.cs
                📂Login
                    📄LoginScenario.cs
                    📄LoginScenarioProvider.cs
            📄Workshop5.Presentation.Console.csproj
    📂Workshop5
        📄Program.cs
        📄Workshop5.csproj

Основной код разделен на три папки: Infrastructure, Presentation и Application. Как можем заметить, все типы в бизнес-логике Application разделены на 3 вида:

Здесь мы приходим к концепции внедрения зависимостей (Dependency Injection, подробнее на learn.microsoft.com).

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

В .NET внедрение зависимостей реализовано с помощью пакета Microsoft.Extensions.DependencyInjection. Пример его работы:

// Сделаем сервисы вывода в консоль
public interface IMyConsole {
    void Write(string output);
};

public class MyConsole {
    void Write(string output) {
        Console.WriteLine(output);
    }
};

// Сделаем сервис вывода имени
public class MyAnotherService(IMyConsole console)
{
    public void SayMyName(string name)
    {
        console.WriteLine(name);
        console.WriteLine("> You're god damn right!");
    }
}

// Создаем коллекцию
var services = new ServiceCollection();

// Добавляем сервисы
services.AddSingleton<IMyConsole, MyConsole>();
services.AddSingleton<MyAnotherService>();

// Билдим поставщик сервисов
var serviceProvider = services.BuildServiceProvider();

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

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

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

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

serviceProvider.GetRequiredService<MyAnotherService>().SayMyName("Walter White");

выведет в консоль текст, несмотря на то, что конструктор MyAnotherService требует сервис IMyConsole - поставщик сервисов догадался об этом и засунул вместо IMyConsole добавленный нами ранее MyConsole.

При помощи расширений в C# мы можем добавить расширение для ServiceCollection, которое пачкой добавляет нужные нам сервисы для нашего проекта, например, Workshop5.Application/Extensions/ServiceCollectionExtensions:

public static class ServiceCollectionExtensions
{
    public static IServiceCollection AddApplication(this IServiceCollection collection)
    {
        collection.AddScoped<IUserService, UserService>();
        collection.AddScoped<IShopService, ShopService>();

        collection.AddScoped<CurrentUserManager>();
        // Здесь мы добавили в качестве аргумента метода фабрику 
        // по созданию реализации интерфейса ICurrentUserService
        collection.AddScoped<ICurrentUserService>(
            p => p.GetRequiredService<CurrentUserManager>());

        return collection;
    }
}

Рассмотрим слой Infrastructure. В файле Infrastructure/Workshop5.Infrastructure.DataAccess/Migrations/01_Initial.cs происходит миграция базы данных. При помощи библиотеки Itmo.Dev.Platform.Postgres мы устанавливаем то, как будет создана (и удалена) наша база данных через SQL-запросы

Здесь же в слое Infrastructure представлены реализации репозиториев, которые связывают слои Application и Infrastructure и содержит SQL-запросы к БД для получения данных и преобразования этих данных в объекты домена

Далее при помощи расширений мы можем добавить репозитории и другие сервисы в провайдер сервисов


В слое Presentation мы реализуем представление в консоль при помощи пакета Spectre.Console

Поведение представления реализуем с помощью сценариев (данный воркшоп реализует сценарии входа в систему и частично выбора магазина). Далее ScenarioRunner предлагает выбрать нужный сценарий пользователю через умную консоль из Spectre.Console и запускает его


В итоге точка входа нашей программы выглядит так:

var collection = new ServiceCollection();

collection
    // Добавляем сервисы из Application
    .AddApplication()
    // Добавляем сервисы из Infrastructure и настраиваем подключение
    // к базе данных
    .AddInfrastructureDataAccess(configuration =>
    {
        configuration.Host = "localhost";
        configuration.Port = 6432;
        configuration.Username = "postgres";
        configuration.Password = "postgres";
        configuration.Database = "postgres";
        configuration.SslMode = "Prefer";
    })
    // Добавляем сервисы из Presentation
    .AddPresentationConsole();

// Собираем провайдер и создаем область жизни
var provider = collection.BuildServiceProvider();
using var scope = provider.CreateScope();

// Синхронный метод, делающий миграцию из `Itmo.Dev.Platform.Postgres`
scope.UseInfrastructureDataAccess();

// Достаем ScenarioRunner
var scenarioRunner = scope.ServiceProvider
    .GetRequiredService<ScenarioRunner>();

// Запускаем цикл, в котором выполняются выбранные пользователем сценарии
while (true)
{
    scenarioRunner.Run();
    AnsiConsole.Clear();
}
  1. Первый воркшоп, посвященный основам C#, не проводился