itmo_conspects

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

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

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

В самом начале развития Computer Science код выглядел как-то так:

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

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

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

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

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

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

Концепции ООП

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

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

Выводы

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

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

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

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

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

public class StudentGroup 
{ 
    public long Id { get; set; } 
    
    public string Name { get; 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; // ну еще лист на хешсетик поменяли 
    
    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)); 
} 

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

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

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

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

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

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

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

В итоге мы можем возвращать 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(); 
} 

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

if (result is AddStudentResult.AlreadyMember) 
{ 
    Console.WriteLine("Student is already member of specified group"); 
    return; 
} 
if (result is AddStudentResult.StudentLimitReached err) 
{
    var message = $"Cannot add student to specified group, maximum student count of {err.Limit} already reached";
    Console.WriteLine(message); 
    return; 
} 
if (result is not AddStudentResult.Success) 
{ 
    Console.WriteLine("Operation finished unexpectedly"); 
    return; 
} 

Console.WriteLine("Student successfully added"); 

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

Domain Driven Design

Domain driven design - проектирование, ориентированное на нужную нам предметную область. Здесь рассмотрим паттерны, которые применяются в DDD

Value Object

Приведем пример:

public class Account 
{ 
    public decimal Balance { get; private set; } 
    
    public void Withdraw(decimal value)     
    { 
        if (value < 0) 
            throw new ArgumentException("Value cannot be negative", nameof(value)); 
            
        Balance -= value;     
    } 
} 

Здесь можно сделать обертку вокруг decimal value, которая будет заниматься валидацией данных:

public struct Money 
{ 
    public Money(decimal value)     
    { 
        if (value < 0)         
        { 
            throw new ArgumentException("Value cannot be negative", nameof(value));         
        } 
        Value = value;     
    } 
    public decimal Value { get; } 
    public static Money operator -(Money left, Money right)     
    { 
        var value = left.Value - right.Value; 
        return new Money(value);     
    } 
} 
public class Account 
{ 
    public Money Balance { get; private set; } 
    public void Withdraw(Money value)     
    { 
        Balance -= value;     
    } 
} 

И в этом случае деньги будут “value object”

Файловая структура

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

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

Single responsibility principle

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

Например: делать класс, который создает отчеты и для 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)    
    {
        ...
    }
}

Преимущества несоблюдения:

Последствия несоблюдения:

Single Responsibility Principle - проектирование типов, таким образом, что они имеют единственную причину для изменения

Open/closed principle

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

Пример несоблюдения OCP:

public enum BinaryOperation
{
    Summation,
    Subtraction,
}
public class BinaryOperand
{
    private readonly int _left;
    private readonly int _right;
    
    public int Evaluate(BinaryOperation operation)    
    {
        return operation switch {
            BinaryOperation.Summation !=>_left + _right
            BinaryOperation.Subtraction !=>_left - _right,        
        };   
    }
}

В этом примере калькулятор использует перечисления для определения оператора и оператор 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 int Evaluate(IBinaryOperation operation) 
        => operation.Evaluate(_left, _right);
}

Создаем интерфейс операции, классы конкретных операторов с их реализацией, и передаем объекты классов в класс BinaryOperand

Open/Closed Principle - проектирование типов, таким образом, что их логику можно расширять, не изменяя их исходный код; тип должен быть открытым для расширения, но закрытым для изменений

Liskov substitution principle

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

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

public record Coordinate(int X, int Y);

public class Creature{
    public void Die()    
    {
        Console.WriteLine("I am dead now");    
    }
}

public class Bird : Creature
{
    public virtual void FlyTo(Coordinate coordinate)    
    {        
        Console.WriteLine("I am flying");    
    }
}

public class Penguin : Bird
{
    public override void FlyTo(Coordinate coordinate)    
    {
        Die();  // it cannot fly :(   
    }
}

public class Bat : Creature
{
    public void FlyTo(Coordinate coordinate)    
    {
        Console.WriteLine("I bat and am flying");    
    }
}

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);        
        }
    }
}

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

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("I am dead now");    
    }
}
public class Bird : CreatureBase { }
public class Penguin : Bird { }
public class Colibri : Bird, IFlyingCreature
{
    public void FlyTo(Coordinate coordinate)    
    {
        Console.WriteLine("I am colibri and I'm flying");    
    }
}
public class Bat : CreatureBase, IFlyingCreature
{
    public void FlyTo(Coordinate coordinate)    
    {
        Console.WriteLine("I am bat and I'm flying");    
    }
}

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

В итоге, получаем, что для летучей мыши не нужны дополнительный if

Liskov Substitution Principle - проектирование иерархий типов, таким образом, что логика дочерних типов не нарушает инвариант и интерфейс родительских типов

Interface segregation principle

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

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

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

А так:

public interface IPrinter
{
    void Print();
}
public interface IMusicPlayer
{
    void Play();
}
public interface IBakery
{
    void BakeBread();
}

Interface segregation principle - проектирование маленьких абстракций, которые ответственны за свой конкретный функционал, а не одной всеобъемлющей, содержащий много различного

Dependency inversion principle

Принцип зависимости инверсий гласит, что реализации должны зависеть только от интерфейсов.

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

Dependency inversion principle - проектирование типов, таким образом, что одни реализации не зависят от других напрямую

Лекция 4. GRASP

GRASP (General Responsibility Assignment Software Principles) - общие принципы распределения ответственности в ПО

GRASP основывается на мыслях из SOLID, в него входят 9 принципов

Informational 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

Почему не использовать информационного эксперта:

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

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);
    }
}

Здесь объект заказа сам подсчитывает стоимость заказа - на нем лежит эта ответственность

Creator

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

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

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;
    }
}

Недостатки Creator:

Controller

Контроллер - переходник между моделями бизнес-логики и моделями представления.

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

  1. Use-case Controller - инкапсулирует один метод (чаще всего мало и неудобно)

  2. Use-case Group Controller - инкапсурирует группу методов из одного класса

  3. Facade Controller - инкапсулирует набор методов из разных классов (чаще всего громоздко)

Low coupling

Coupling (зацепление) - мера зависимости модулей друг между другом

Сильное зацепление (High coupling) - это плохо

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

High cohesion

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

Слабая связность (Low cohesion) - это плохо

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

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

Indirection

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

Interface Segregetion (Разделение интерфейса) - любое взаимодействие с данными, поведениями, модулями, реализованное не напрямую, а через какой-либо интерфейс

Перенаправление тесно связано с ISP и DIP. Принцип перенаправления используется в архитектуре Model-View-Controller: бизнес-логика из Model общается с сущностями представления View через контроллер Controller

Polymorphism

пу-пу-пу🦆

кря

Protected variations

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

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

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

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

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

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;

        // Apply discounts and coupons
        ...

        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;

        // Apply discounts and coupons
        ...

        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;

        // Apply discounts and coupons
        ...

        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);

        // Apply discounts and coupons
        ...

        return _paymentFactory.Create(totalCost);
    }
}

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

Builder

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

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

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

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());
    }
}

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

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(); 
    private 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

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

Код с воркшопа можно посмотреть здесь: 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 интерфейсов3: 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. Структурные паттерны

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

Adapter

Адаптер позволяет использовать несовместимые вещи совместно. Для начала определимся с терминами:

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

Adaptee (Адаптируемый) - адаптируемый тип (британская вилка drawing)

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

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

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. Меняем реализацию - засовываем в этот адаптер асинхронный логгер

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

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

Bridge

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

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;
}

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

Можем заметить, что по сути мост - это адаптер, поэтому мост тоже соблюдает OCP и DIP. Отличие моста от адаптера в том, что мост проектируется изначально

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() { ... }
}

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

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, декорируемого объекта, сделаем декоратор, реализующий наш интерфейс и расширяющий функционал:

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

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

Возьмем наш старый добрый сервис:

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

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

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

Facade

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

Фасад рассматривался как контроллер в GRASP. Фасад

Но фасад может быть полезен в request-response модели, например, как объектная обертка API вызовов

Flyweight

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

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

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);
    }
}

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);
}

и абстрактный класс:

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

Responsibility chain

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

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

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

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);
    }
}

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

Command

Команда

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

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

Пример использования команд в веб-приложении: 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

Снимок

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

Ориджинатор (Originator) - сущность, снимки которой мы хотим сохранять

Кейртейкер (Caretaker) - сущность, которая хранит снимки

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

Пример:

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. Многослойная архитектура

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

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

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

MVX

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

View реализует представление системы (например: GUI, API), требует изменений, когда изменяется способ представления

Controller является посредником данных между Model и View, требует изменений, когда изменяется механизм взаимодействия Model и View

Вместо Controller может быть Presenter или другая угодная буква

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

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

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

Presentation ответственен за реализацию представления системы, за передачу запросов к системе в слой бизнес-логики

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

Data access ответственен за реализацию персистентности данных

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

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

Богатая модель данных описывает полноценные объекты, логика находится в типах, описывающих домен

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

В гексагональной архитектуре у компонента бизнес-логики есть порты:

Первичный порт (Input port, также Primary, Driving) - принимает данные

Вторичный порт (Output port, также Secondary, Driven) - отправляют и хранят данные компонентам, которые ждут отклик от приложения, суммарно их можно разделить на получателей и репозитории

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

Важно: не имеет ничего общего с шестиугольниками

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

Луковая (также “чистая”, “clean”) архитектура основывается на гексагональной, но в отличии от нее компонент с бизнес-логикой разделяется на части 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. Несмотря на это, некоторые источники(1, 2, 3) говорят, что:

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

  2. Почему второй? А вот первый был у y26 

  3. Говорилось, что лучше бы эти два интерфейса разделить на два файла, так что так не делайте