Все презентации к лекциям можно найти по ссылке github.com/is-oop-y27
В самом начале развития 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 основных концепции ООП:
Инкапсуляция - объединение данных и их поведения
Сокрытие - управление доступа к полям класса, тем самым сохранение инварианта. Стоит заметить, что сокрытие не является основной концепцией ООП, так как сокрытие необязательно в языках с объектно-ориентированной парадигмой, например, Python
Полиморфизм
Концепция полиморфизма заключается в более абстрактном понимании объектов
Полиморфизм подтипов - отделение абстракции от реализации, позволяющее пользователю прозрачно использовать различные реализации поведений
Примером абстракции может быть объект для доступа к базе данных - мы можем создать классы для доступа к базам данным SQL и NoSQL, которые имеют одни и те же публичные методы с одинаковыми аргументами - и тогда мы приходим к понятию интерфейса, который описывает методы у классов
Наследование
Реализация (наследование поведений): в C# реализовывать интерфейсы могут как классы, так и структуры. Говорят, что тип реализует интерфейс (класс
Pointреализует интерфейсIPoint
Наследование реализаций: используются классы, в C# одна структура не может быть унаследована от другой, либо от класса. Говорят, что класс является наследником другого класса, либо же его подклассом (класс
Catявляется наследником классаAnimal)
При этом наследники могут переопределять методы класса/интерфейса и определять новые
Объект - набор атрибутов и поведений, реализаций и данные которого сокрыты от конечного пользователя объекта. Также абстракция, представляющая какой-то объект моделируемой предметной области
Также выделяют композицию, агрегацию и ассоциацию1:
Иммутабельность (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);
}
}
В итоге мы поставили ограничение, что айди и имя группы мы можем только инициализировать.
Если же у нас есть метод, который возвращает какой-то X, то неплохо было бы определиться, что будет происходить, если метод не нашел X. Тогда можно действовать так:
nullТогда соответственно будем именовать методы 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 - проектирование, ориентированное на нужную нам предметную область. Здесь рассмотрим паттерны, которые применяются в DDD
Приведем пример:
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”
Также структура файлов проекта должна быть семантической, а не инфраструктурной для упрощенного поиска той или иной сущности

Принцип единственной ответственности (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 - проектирование типов, таким образом, что они имеют единственную причину для изменения
Принцип открытости и закрытости гласит, что программные сущности должны быть открытыми для расширения и закрытыми для изменения
Пример несоблюдения 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 - проектирование типов, таким образом, что их логику можно расширять, не изменяя их исходный код; тип должен быть открытым для расширения, но закрытым для изменений
Принцип подстановки Лисков гласит, что при замене похожих объектов логика программы не должна нарушаться
Например: создадим классы для обычной птицы, пингвина и летучей мыши, чтобы заставить их мигрировать:
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 - проектирование иерархий типов, таким образом, что логика дочерних типов не нарушает инвариант и интерфейс родительских типов
Принцип разделения интерфейса является аналогом 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 - проектирование типов, таким образом, что одни реализации не зависят от других напрямую
GRASP (General Responsibility Assignment Software Principles) - общие принципы распределения ответственности в ПО
GRASP основывается на мыслях из SOLID, в него входят 9 принципов
Информация должна отбрабатываться там, где она содержится
Приведем пример заказа и создателя чека:
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);
}
}
Здесь объект заказа сам подсчитывает стоимость заказа - на нем лежит эта ответственность
Ответственность за создание используемых объектов должна лежать на типах, их использующих
Приведем пример: здесь сервис заказа создает позицию заказа, которая передается в объект заказа
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:
Контроллер - переходник между моделями бизнес-логики и моделями представления.
Различают 3 вида контроллеров:
Use-case Controller - инкапсулирует один метод (чаще всего мало и неудобно)
Use-case Group Controller - инкапсурирует группу методов из одного класса
Facade Controller - инкапсулирует набор методов из разных классов (чаще всего громоздко)
Coupling (зацепление) - мера зависимости модулей друг между другом
Сильное зацепление (High coupling) - это плохо
Например: есть класс DataProvider, методы которого выводят температуру в конкретном месте и используемую сборщиком мусора память. Логически эти данные не связаны, поэтому лучше всего ослабить их зацепление - создать 2 отдельных класса для вывода температуры и для вывода памяти
Cohesion (связность) - мера логической соотнесенности логики в рамках модуля
Слабая связность (Low cohesion) - это плохо
Пример: сделаем класс DataMonitor, который отображает нужную метрику от DataProvider в зависимости от переданного enum MetricType; так как мы работаем с перечислением, то не избежать использования switch, а значит код будет трудно расширять - нарушается OCP.
В этом случае создадим интерфейс для DataProvider, реализации которого будут использоваться в DataMonitor
Object Indirection (Объектное перенаправление) - любое взаимодействие с данными, поведениями, модулями, реализованное не напрямую, а через какой-либо агрегирующий их объект
Interface Segregetion (Разделение интерфейса) - любое взаимодействие с данными, поведениями, модулями, реализованное не напрямую, а через какой-либо интерфейс
Перенаправление тесно связано с ISP и DIP. Принцип перенаправления используется в архитектуре Model-View-Controller: бизнес-логика из Model общается с сущностями представления View через контроллер Controller
пу-пу-пу🦆

Protected variations (Устойчивость к изменениям) подразумевает о поиске условий, при которых инвариант объекта может сломаться; в этом случае применяется сокрытие и вытеснение доступа к полям через интерфейс
Pure fabrication (Чистая выдумка) подразумевает создание выдуманной сущности, которая не входит в моделирование бизнес-логики. Чаще всего это инфраструктурные модули (логгер, доступ к базе данных, т.д.). Такие типы не рекомендуется вносить в доменную модель
В ходе разработки возникают классы, объекты которых создаются уж слишком тяжело и громоздко. Для этих случаев разрабатывают другие методы/объекты, за которыми лежит ответственность за их созданием
Фабричный метод - разделение логики и создания объектов на иерархию типов
Пример: у нас есть объект заказа (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.
Фабричный метод применяется для переиспользования логики создания на наборе типов. Но при этом фабричный метод считается антипаттерном из-за следующих недостатков:
Calculate для разных типовАбстрактная фабрика (или просто фабрика) - вынесение логики создания объектов в отдельные типы, объекты которых будут ответственны только за это
При использовании фабричного метода возникает такая проблема: мы хотим использовать логику создания не только в пределах нашего 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);
}
}
При этом заметить следующие преимущества у абстрактной фабрики:
Билдер (строитель) - объект, при помощи которого мы можем создать составной объект. В билдере мы можем разбить логику сбора аргументов на методы, уменьшая мутабельность, задавать некоторые значения по умолчанию
Разделяют 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();
Здесь мы принуждаем к порядку сбора данных: адрес -> тема -> тело письма (опционально)
Примером работы билдера может явялется процесс создания пиццы из моей любимой франшизы пиццерий Додо Пицца: в билдере мы можем принять такие типы, как соус, начинка, топпинги, чтобы билдер сам сбилдил нам пиццу
С помощью прототипа мы можем упростить себе копирование объекта. Почему не пользоваться просто конструктором:
Примитивный прототип может быть таким:
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 возвращает именно тип наследника
Синглтон - объект, для которого мы гарантируем, что одновременно будет существовать не больше одного экземпляра. Синглтоном может быть, например, глобальный кеш. Пример:
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;
}
Синглтон считается антипаттерном и вот почему:
На этой лекции был воркшоп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;
}
На этой лекции разберем структурные паттерны, которые помогают в проектировании проектной модели
Адаптер позволяет использовать несовместимые вещи совместно. Для начала определимся с терминами:
Target (Цель) - целевой интерфейс, через который мы хотим взаимодействовать с объектом, изначально его не реализующий (европейская вилка
)
Adaptee (Адаптируемый) - адаптируемый тип (британская вилка
)
Adapter (Адаптер) - обёртка, реализующая целевой интерфейс, содержащая объект адаптируемого типа и перенаправляющая в него вызовы поведений целевого интерфейса (сам адаптер, кусок белого пластика, короче
)
И с помощью этого мы хотим достичь полиморфизма между несовместимыми объектами. Пример: есть два типа логгеров, которые имеют разный интерфейс, и хотим к ним обращаться, как к одному:
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 шага:
Меняем абстракцию - создаем крутой адаптер, интерфейс которого поддерживает и старую, и новую реализации, и используем этот адаптер в нашем коде
Меняем реализацию - засовываем в этот адаптер асинхронный логгер
Таким образом, мы получаем два этапа, которые легче тестировать по отдельности
Adapter (Адаптер) - промежуточный тип, использующий объект одного типа, для реализации интерфейса другого типа
Допустим, что у нас есть абстракции сложное (низкоуровневые) и простые (верхнеуровневые). Тогда, чтобы через простую абстракцию использовать сложную создадим мост
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. Отличие моста от адаптера в том, что мост проектируется изначально
Компоновщик - представление древовидной структуры объектов в виде одного композитного объекта
Допустим, что у нас есть куча объектов, реализующих один интерфейс, и мы хотим сделать со всеми ними какое-то действие:
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();
}
}
Декоратор - тип-обёртка над объектом абстракции, которую он реализует, добавляя к поведению объекта новую логику
Допустим у нас есть абстракция какого-то абстрактного сервиса:
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 (Заместитель) - тип-обёртка, реализующий логику контроля доступа к объекту, реализующему абстракцию,которую реализует он сам
Возьмем наш старый добрый сервис:
public interface IService
{
void DoOperation(OperationArgs args);
}
public class Service : IService
{
public void DoOperation(OperationArgs args) { }
}
Рассмотрим несколько видов прокси:
Виртуальный прокси (Virtual proxy):
Если у нас есть какой-то прям тяжелый объект, который мы хотим инициализировать тогда, когда он нам прям нужен, то нам поможет виртуальный прокси:
public class VirtualServiceProxy : IService
{
private readonly Lazy<IService> _service =
new Lazy<IService>(() => new Service());
public void DoOperation(OperationArgs args)
{
_service.Value.DoOperation(args);
}
}
Защищающий прокси (Defensive proxy):
Юзаем, если хотим ограничить доступ к объекту:
public class ServiceAuthorizationProxy : IService
{
private readonly IService _service;
private readonly IUserInfoProvider _userInfoProvider;
public void DoOperation(OperationArgs args)
{
if (_userInfoProvider.GetUserInfo().IsAuthenticated)
_service.DoOperation(args);
}
}
Кеширующий прокси (Caching proxy):
Юзаем, если операция дорогая, и мы не хотим каждый раз заново вызывать ее
public class CachingServiceProxy : IService
{
private readonly IService _service;
private readonly Dictionary<OperationArgs, OperationResult> _cache;
public OperationResult DoOperation(OperationArgs args)
{
if (_cache.TryGetValue(args, out var result))
return result;
return _cache[args] = _service.DoOperation(args);
}
}
Удаленный прокси (Remote proxy):
Юзаем, если хотит работать с интерфейсом, который не лежит в программе (например, класс, оборачиющий http-вызовы)
Как можно заметить, прокси очень подозрительно похож на декоратор, НО:
Facade (Фасад) - оркестрация одной или набора сложных операция в каком-либо типе
Фасад рассматривался как контроллер в GRASP. Фасад
Но фасад может быть полезен в request-response модели, например, как объектная обертка API вызовов
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);
}
}
На этом воркшопе будут примеры использования структурных паттернов. Код воркшопа: https://github.com/is-oop-y27/workshop-3
Техническое задание: на основе модели создания статей из второго воркшопа сделать поддержку математических выражений.
Математические выражении могут содержать константы, переменные, бинарные операции (сложение, вычитание, умножение, деление), а также при данных значениях переменных уметь вычисляться.
Заметим, что любое выражение можно представить в виде бинарного дерева.
Сделаем в проекте второго воркшопа папку Expressions, где будет независимая от моделей статей реализация выражений. В ней создадим интерфейс IExpression с двумя методами:
string Format() для преобразования объекта выражения в строкуExpressionEvaluationResult Evaluate(IExpressionEvaluationContext context) для вычисления выраженияДля вычисления выражения сделаем объект контекста, в котором будем производить вычисления.
Контекст IExpressionEvaluationContext содержит в себе имена переменных и их значения.
Внутри контекст вычисления выражения
будет содержать словарь. У контекста сделаем метод, возвращающий резалт-тайп со значением переменной.
Для него же сделаем билдер.
Операнды выражения в нашей модели являются либо константными значениями, либо переменными. Для констант сделаем
класс ConstantExpression, реализующий IExpressionValue, наследующийся от IExpression. У константного значения
сделаем свойство Value
Для VariableExpression, реализующий IExpression, сделаем метод Evalute, который достает значение из словаря контекста
Чтобы применять арифметические операторы к выражениям, сделаем BinaryOperatorExpression, принимающий два IExpression и
IBinaryOperator. Реализации IBinaryOperator будут хранить в себе логику обработки двух выражений. По сути IBinaryOperator является поведенческим паттерном “Стратегия”. При вычислении BinaryOperatorExpression будет пытаться вычислить значения у своих детей; при успехе он применит операцию к полученным значениям.
Теперь сделаем Articles.Extensions для того, чтобы связать модели статей и матвыражений
Сделаем класс ExpressionRenderable - он будет мостом между интерфейсов IRenderable и IExpression. При вызове Render объект будет возвращать текстовое представление выражения.
Также сделаем декоратор StyledExpressionDecorator - он реализует интерфейс IExpression и содержит в себе модификатор для текста, при вызове метода Format он будет возвращать сформатированный текст. Аналогично сделам для интерфейса IExpressionValue
На этом воркшопе будут рассматриваться поведенческие паттерны. Рассматриваемый код доступен в этом репозитории: https://github.com/is-oop-y27/workshop-4
Шаблонный метод
Проблема: у нас в методе есть кусочек кода, который мы хотим параметризировать - изменять для разных обстоятельств этот кусочек кода.
Пример: у нас есть куча сотрудников, у которых есть количество выполненных задач и количество отработанных часов, хотим иметь возможность делать сортировку по этим параметрам. Сделаем 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
Стратегия
Проблема: та же, что и с шаблонным методом - параметризируем задачу; отличие в том, что в шаблонном методе используем наследование, а в стратегии - композицию
Пример: тот же самый: сортировка сотрудников. Здесь вынесем метод 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
Цепочка обязанностей
Проблема: хотим иметь настраиваемое подобие 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
Издатель - подписчик
Проблема: у нас есть сущность, которая производит какие-то события, и сущности, которые хотят отслеживать эти события
Пример: есть годовалый ребенок, о чьих события родители хотели бы знать. В этом случае ребенок - издатель событий, а родители - подписчики. Другой пример: чатик и сообщения, в этом случае чат - это издатель, а пользователи - подписчики:
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
Команда
Вместо того, чтобы вызывать метод, мы создаем объект, метод которого выполняет нужный нам метод. В итоге с такими объектами появляется больше возможностей, чем с обычными методами: их мы можем вызывать, когда и как захотим, например, фильтровать команды, логгировать, устранять дубликаты.
Пример использования команд: todo список, где команда - это изменение списка. В этом случае для каждой команды мы можем определить обратную к ней и откатывать состояние todo списка
Пример использования команд в веб-приложении: https://github.com/is-oop-y27/workshop-4/tree/master/src/5_Command
Визитор
Не всегда какая-то дополнительная логика хорошо привязана к объектной модели. С помощью визитора можно добавлять дополнительные операции, не модифицируя наш объект
Пример: делаем вывод дерева файловой системы, для этого сделаем визитор, реализующий этот интерфейс с методами посещения файла и директории:
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
Снимок
В паттерне снимок есть 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
Но в каком-то случае использования, если изменяемый объект тяжелый, а изменения маленькие, то лучше использовать команды
Состояние
По сути, просто конечная машина состояний (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
Итератор
Ну тут нечего говорить, паттерн, при помощи которого можем проитерироваться по сложной штуковине.
В C# итерируемые объекты реализованы через интерфейс IEnumerable, метод GetEnumerator
которого возвращает “итератор” - реализацию интерфейса IEnumerator:
GetNext() - подвинуть итератор впередCurrent - получить значение по итераторуReset() - сбросить итератор к начальному значениюПрименяя это к примеру файловой системы выше, с помощью методов расширения:
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
Архитектура приложения - способ структурирования программных компонентов приложения для управления сложностью
Задача состоит с том, чтобы качественно сделать декомпозицию компонентов, уменьшить их переиспользование, были низкое зацепление и высокая связность, а также существовала возможность расширять систему, добавляя реализации с минимальным эффектом на существующие решения
В итоге мы хотим разделить модули на роли: обрабатывающие данные согласно бизнес-логике, реализующие представление и посредник между ними
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
Доменные модели - определения бизнес-правил
Доменные сервисы - определения юзкейсов
Архитектура выделяет слой приложения - связующее звено между инфраструктурными абстракциями и доменом
На пятом воркшопе разбирался код абстрактного магазина, спроектированного по трехслойной архитектуре. Код воркшопа: 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 вида:
Рекорды, хранящие данные: User, Shop, Product, ProductCategory
Интерфейсы сервисов, реализации которых хранят логику: ICustomerUserService, IUserService, IShopService
Интерфейсы репозиториев для доступа к базе данных
Здесь мы приходим к концепции 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();
Теперь, получая поставщик сервисов, мы можем запросить какой-либо сервис через метод:
GetService<T>(), который возвращает сервис типа T?GetRequiredService<T>(), который возвращает сервис типа T или вызывает исключениеТеперь участники архитектуры будут доставать из этого контейнера нужным им интерфейс сервиса.
Также у сервисов есть циклы жизни, которые устанавливаются непосредственно до билда провайдера:
Transient - каждый раз при вызове GetService создается новый объект сервиса
Scoped - сервис живет в рамках одного явно установленного скоупа
// начало скоупа
var scope = provider.CreateScope();
// вызов сервиса
T t = scope.ServiceProvider.GetRequiredService<T>();
// вызов того же сервиса
T t2 = scope.ServiceProvider.GetRequiredService<T>();
// конец скоупа
scode.Dispose();
Сервисы, возвращенные непосредственно через провайдера, живут в глобальном скоупе, который заканчивается с удаление провайдера
Singleton - единственный объект сервиса на контейнер (как в примере выше)
Самая главное преимущество этого провайдера - внедрение зависимостей. Для примера выше такой код:
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, 2, 3) говорят, что:
Почему второй? А вот первый был у y26 ↩
Говорилось, что лучше бы эти два интерфейса разделить на два файла, так что так не делайте ↩