Все презентации к лекциям можно найти по ссылке 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 ↩
Говорилось, что лучше бы эти два интерфейса разделить на два файла, так что так не делайте ↩