эксперт в майнкрафт апи
-
Автор темы
- #1
Всем привет!
На протяжении длительного времени я изучал парадигму "чистого кода/архитектуры приложения" и готов показать некий пример, возможно, заинтересовав кого либо из тех, кто будет это читать
Язык на котором будет писаться код - Java, но никакие трудные фьючеры джавы не будут использованы.по моим меркам джаву в данной статье можно считать псевдоязыком понятным любому.в не совсем очевидных местах оставил комментарии.повторить данные трюки можно в любом ОО языке.
Глава 0: кому это нужно?
это пригодиться тем, кто пишет код с друзьями/просто хочет улучшить качество своего кода.
на практике в проектах которые не будут жить больше нескольких месяцев нет смысла все это использовать.оверхед.
Глава 1: вступление.
Начну статью с того, что же мы разберем.
Мы пробежимся по всем 5 принципам SOLID, применим несколько паттернов программирования из GoF
В статье я сначала напишу "плохой код", а потом мы вместе разберем что же там не так с точки зрения SOLID + перепишем его по правилам SOLID и применим паттерны программирования.
Все будет основано на примерах.
Lets go
Глава 2: обзор случая.
Нам понадобилось сохранение конфигов в нашем чите.(IO компонент)
(Случай взят из воздуха.Он субъективно нравится мне для описания проблем, их решения.)
Глава 3: написание "плохого кода".
На протяжении длительного времени я изучал парадигму "чистого кода/архитектуры приложения" и готов показать некий пример, возможно, заинтересовав кого либо из тех, кто будет это читать
Язык на котором будет писаться код - Java, но никакие трудные фьючеры джавы не будут использованы.по моим меркам джаву в данной статье можно считать псевдоязыком понятным любому.в не совсем очевидных местах оставил комментарии.повторить данные трюки можно в любом ОО языке.
Глава 0: кому это нужно?
это пригодиться тем, кто пишет код с друзьями/просто хочет улучшить качество своего кода.
на практике в проектах которые не будут жить больше нескольких месяцев нет смысла все это использовать.оверхед.
Глава 1: вступление.
Начну статью с того, что же мы разберем.
Мы пробежимся по всем 5 принципам SOLID, применим несколько паттернов программирования из GoF
В статье я сначала напишу "плохой код", а потом мы вместе разберем что же там не так с точки зрения SOLID + перепишем его по правилам SOLID и применим паттерны программирования.
Все будет основано на примерах.
Lets go
Глава 2: обзор случая.
Нам понадобилось сохранение конфигов в нашем чите.(IO компонент)
(Случай взят из воздуха.Он субъективно нравится мне для описания проблем, их решения.)
Глава 3: написание "плохого кода".
Java:
package ru.metafaze.io;
import java.io.InputStream;
import java.io.OutputStream;
public class InputOutputStream {
private final InputStream input;
private final OutputStream output;
public InputOutputStream(InputStream input, OutputStream output) {
this.input = input;
this.output = output;
}
public void writeByte(int value) {
try {
/**
* метод InputStream.write имеет сигнатуру int, но по факту записывает только 1 байт
*/
output.write(value);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
public void writeShort(int value) {
writeByte(value & 0xFF);
writeByte(value >> 8);
}
public void writeInt(int value) {
writeShort(value & 0xFFFF);
writeShort(value >> 16);
}
public int readByte() {
try {
/**
* метод InputStream.read имеет сигнатуру int, но по факту читает только 1 байт
*/
return input.read();
} catch (Exception e) {
throw new RuntimeException(e);
}
}
public int readShort() {
return readByte() | readByte() << 8;
}
public int readInt() {
return readShort() | readShort() << 16;
}
}
Вдруг, вас осинило...Значения все таки лучше зашифровать от дядей Эдиков...
Ну, что ж поделать, добавим в этот же класс xor предварительно переименовав InputOutputStream в XorInputOutputStream.
Ну, что ж поделать, добавим в этот же класс xor предварительно переименовав InputOutputStream в XorInputOutputStream.
Java:
package ru.metafaze.io;
import java.io.InputStream;
import java.io.OutputStream;
public class XorInputOutputStream {
private final InputStream input;
private final OutputStream output;
private final int xorKey;
public XorInputOutputStream(InputStream input, OutputStream output, int xorKey) {
this.input = input;
this.output = output;
this.xorKey = xorKey;
}
public void writeByte(int value) {
try {
/**
* метод InputStream.write имеет сигнатуру int, но по факту записывает только 1 байт
*/
output.write(value ^ xorKey);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
public void writeShort(int value) {
writeByte(value & 0xFF);
writeByte(value >> 8);
}
public void writeInt(int value) {
writeShort(value & 0xFFFF);
writeShort(value >> 16);
}
public int readByte() {
try {
/**
* метод InputStream.read имеет сигнатуру int, но по факту читает только 1 байт
*/
return input.read() ^ xorKey;
} catch (Exception e) {
throw new RuntimeException(e);
}
}
public int readShort() {
return readByte() | readByte() << 8;
}
public int readInt() {
return readShort() | readShort() << 16;
}
}
Потом, вдруг, ты понял, что было бы неплохо добавить компрессию.
Но что мы получим в итоге?CompressedProtectedInputOutputStream?
Глава 4. в чем проблема данного подхода?
Проблем тут уже ОЧЕНЬ много.Дело в том, что ваш класс уже на данном этапе трансформирует числа в байты, шифрует их, компрессит(!).Это
1) усложняет в миллион раз анализ кода написанного внутри
2) делает абсолютно невозможным переиспользование.ваш проект будет разрастаться в размерах со скоростью света.вы будете городить один и тот же код под одинаковые задачи.
можно продолжать ещё очень долго, но давайте перейдем к SOLID.
Глава 5. SOLID - что это за хуйня?
SOLID - акроним который скрывает в себе 5 принципов которые расшифровываются как:
SRP - принцип единственной ответственности.(single responsibility principle)
OCP - принцип открытости для расширения, закрытости для изменения.(open-closed principle)
LSP - принцип подставления барбары лисков.(не бойтесь названия, это ирл никнейм бабушки)(Liskov substitution principle)
ISP - принцип разделения интерфейса.(interface segregation principle)
DIP - принцип инверсии зависимостей.(dependency inversion principle)
Глава 6. В чем тут нарушение принципов?Что они вообще из себя представляют?
SRP говорит о том, что ваш компонент должен иметь лишь одну ответственность.Как это понять?Роберт Мартин описывает это как одну причину для изменения.И ведь действительно
1) вдруг появится потребность в изменении шифра?
2) вдруг появится потребность в изменении трансформации числа в байты?
3) вдруг появится потребность в изменении компрессии?
Это абсолютно 3 не связанных между собой задачи которые и противоречат принципу единственной ответственности.
Тут важная ремарка, не стоит проводить декомпозицию до максимально возможного предела.Дробить пока дробиться - плохой подход.В адекватном случае в следствии "правильной ответственности" вы получите код который сможете переиспользовать.
Это самый легкий принцип(на первый взгляд, на самом деле он самый трудный), но он убивает миллион зайцев сразу :
ISP
(он затрагивает даже принципы GRASP)
High Cohesion(высокое сцепление) <- говорят что лучше не переводить этот принцип на русский язык.
OCP говорит о закрытости для изменения(старого кода) и открытости для расширения(старого кода).Т.е новую функциональность старым компонентам вы должны вводить наследованием, композицией и тд.
Не стоит брать этот принцип близко к сердцу, разумеется баги нужно фиксить изменением старого кода)
LSP - самый ебанутый принцип который к нам к тому же и не относится.
Его ебанутость заключается в том, что его название не отображает его смысл, отнюдь, он отображает фамилию бабушки которая его придумала.
Заключается он в том, что нельзя менять сигнатуру виртуальных методов при наследовании, добавлять исключения не описанные в начале иерархии.Можете не беспокоиться, хуй вам компилятор даст это сделать в ОО языках на которых пишут читы.
Так же этот принцип описывает то, что компоненты при наследовании должны уметь "встрять" за своего родственника по иерархии.Т.е если метод принимает Button, то MegaSuperPuperButton(extends Button) в любом случае должен корректно выполняться в том же методе.
ISP - разбивай свои интерфейсы по максимуму(также как с SRP.знайте грани декомпозиции).
DIP - с первого взгляда самый трудный и непонятный принцип.Вот его описание с wiki(в книге Роберта Мартина вроде как звучит как то похуже) :
Но что мы получим в итоге?CompressedProtectedInputOutputStream?
Глава 4. в чем проблема данного подхода?
Проблем тут уже ОЧЕНЬ много.Дело в том, что ваш класс уже на данном этапе трансформирует числа в байты, шифрует их, компрессит(!).Это
1) усложняет в миллион раз анализ кода написанного внутри
2) делает абсолютно невозможным переиспользование.ваш проект будет разрастаться в размерах со скоростью света.вы будете городить один и тот же код под одинаковые задачи.
можно продолжать ещё очень долго, но давайте перейдем к SOLID.
Глава 5. SOLID - что это за хуйня?
SOLID - акроним который скрывает в себе 5 принципов которые расшифровываются как:
SRP - принцип единственной ответственности.(single responsibility principle)
OCP - принцип открытости для расширения, закрытости для изменения.(open-closed principle)
LSP - принцип подставления барбары лисков.(не бойтесь названия, это ирл никнейм бабушки)(Liskov substitution principle)
ISP - принцип разделения интерфейса.(interface segregation principle)
DIP - принцип инверсии зависимостей.(dependency inversion principle)
Глава 6. В чем тут нарушение принципов?Что они вообще из себя представляют?
SRP говорит о том, что ваш компонент должен иметь лишь одну ответственность.Как это понять?Роберт Мартин описывает это как одну причину для изменения.И ведь действительно
1) вдруг появится потребность в изменении шифра?
2) вдруг появится потребность в изменении трансформации числа в байты?
3) вдруг появится потребность в изменении компрессии?
Это абсолютно 3 не связанных между собой задачи которые и противоречат принципу единственной ответственности.
Тут важная ремарка, не стоит проводить декомпозицию до максимально возможного предела.Дробить пока дробиться - плохой подход.В адекватном случае в следствии "правильной ответственности" вы получите код который сможете переиспользовать.
Это самый легкий принцип(на первый взгляд, на самом деле он самый трудный), но он убивает миллион зайцев сразу :
ISP
(он затрагивает даже принципы GRASP)
High Cohesion(высокое сцепление) <- говорят что лучше не переводить этот принцип на русский язык.
OCP говорит о закрытости для изменения(старого кода) и открытости для расширения(старого кода).Т.е новую функциональность старым компонентам вы должны вводить наследованием, композицией и тд.
Не стоит брать этот принцип близко к сердцу, разумеется баги нужно фиксить изменением старого кода)
LSP - самый ебанутый принцип который к нам к тому же и не относится.
Его ебанутость заключается в том, что его название не отображает его смысл, отнюдь, он отображает фамилию бабушки которая его придумала.
Заключается он в том, что нельзя менять сигнатуру виртуальных методов при наследовании, добавлять исключения не описанные в начале иерархии.Можете не беспокоиться, хуй вам компилятор даст это сделать в ОО языках на которых пишут читы.
Так же этот принцип описывает то, что компоненты при наследовании должны уметь "встрять" за своего родственника по иерархии.Т.е если метод принимает Button, то MegaSuperPuperButton(extends Button) в любом случае должен корректно выполняться в том же методе.
ISP - разбивай свои интерфейсы по максимуму(также как с SRP.знайте грани декомпозиции).
DIP - с первого взгляда самый трудный и непонятный принцип.Вот его описание с wiki(в книге Роберта Мартина вроде как звучит как то похуже) :
- A. Модули верхних уровней не должны зависеть от модулей нижних уровней. Оба типа модулей должны зависеть от абстракций.
- B. Абстракции не должны зависеть от деталей. Детали должны зависеть от абстракций.
На самом деле он самый легкий относительно его пользы.
Глава 6: Мы нарушили только SRP?
Нет. Мы нарушили все кроме принципа подставления Барбары Лисков.
OCP - мы ввели функциональность изменяя старый код.
ISP - наш класс имеет интерфейс записи, чтения.(относительно спорный момент в данном контексте, но в огромном проекте лучше не выебываться и все таки разбивать IO на две иерархии для переиспользования.Мало ли вашему второму другу кодеру понадобится только Reader?)
DIP - подход изначально был вне инверсии зависимостей.
Глава 7: Перепишем код.
Для начала поподробнее расскажу про DIP, он очень важный в построении любого компонента.Формулировка очень трудная, на самом деле все в разы легче, а импакта миллион.
Dependency qqqqqq111111 -(повторюсь)
- A. Модули верхних уровней не должны зависеть от модулей нижних уровней. Оба типа модулей должны зависеть от абстракций.
- B. Абстракции не должны зависеть от деталей. Детали должны зависеть от абстракций.
Уровень абстракции - уровень того, насколько близко лежит решение задачи.
Если бы это можно было б визуализировать, я б визуализировал так :
Уровень абстракции уменьшается с увеличением размера стека, где вершина стека всегда является самым низким уровнем абстракции по отношению к элементам которые распологаются ниже по стеку.
Если уж разъяснять на пальцах, то метод где вы вызываете writeInt - высокий уровень абстракции.Ведь вам совсем похуй что там происходит внутри, вы просто пишите несколько символов которые запускают огромную задачу.Для вас это все та же абстракция "writeInt".
То есть место где мы будем юзать наш IO(высокий уровень абстракции) не должен зависить от реализации интерфейса(относительно низкого уровня абстракции), отнюдь, они должны оба зависеть от интерфейса(абстракции).
Компонент A имея потребность в компоненте B описывает интерфейс который ему нужен.компонент B в свою очередь реализует его.Таким образом компонент A зависит от интерфейса, компонент B зависит от интерфейса, а не компонент A зависит от B.
Поэтому это и называют инверсией зависимостей.
Это избавляет нас от конкретной реализации и дает по полной программе насладиться полиморфизмом.
+ Low Coupling из GRASP в подарок.
Также реализуем ISP, разбивая Input и Output на отдельные иерархии.Это даст нам возможность переиспользования в других случаях.
Приступим.
Если бы это можно было б визуализировать, я б визуализировал так :
Уровень абстракции уменьшается с увеличением размера стека, где вершина стека всегда является самым низким уровнем абстракции по отношению к элементам которые распологаются ниже по стеку.
Если уж разъяснять на пальцах, то метод где вы вызываете writeInt - высокий уровень абстракции.Ведь вам совсем похуй что там происходит внутри, вы просто пишите несколько символов которые запускают огромную задачу.Для вас это все та же абстракция "writeInt".
То есть место где мы будем юзать наш IO(высокий уровень абстракции) не должен зависить от реализации интерфейса(относительно низкого уровня абстракции), отнюдь, они должны оба зависеть от интерфейса(абстракции).
Компонент A имея потребность в компоненте B описывает интерфейс который ему нужен.компонент B в свою очередь реализует его.Таким образом компонент A зависит от интерфейса, компонент B зависит от интерфейса, а не компонент A зависит от B.
Поэтому это и называют инверсией зависимостей.
Это избавляет нас от конкретной реализации и дает по полной программе насладиться полиморфизмом.
+ Low Coupling из GRASP в подарок.
Также реализуем ISP, разбивая Input и Output на отдельные иерархии.Это даст нам возможность переиспользования в других случаях.
Приступим.
Java:
package ru.metafaze.io;
public interface BaseInputStream {
int readByte();
int readShort();
int readInt();
}
Java:
package ru.metafaze.io;
public interface BaseOutputStream {
void writeByte(int value);
void writeShort(int value);
void writeInt(int value);
}
Я предпочту делегирование для реализации компрессии, крипта.
Почему не наследование?Дело в том, что наследование в некоторых случаях(90%) - антипаттерн.Не буду много городить, в двух словах, нарушение инкапсуляции и ещё несколько проблем которые появляются в следствии наследования.Делегирование в данном контексте сыпит нам лишь плюсы которые мы сейчас и увидим.
Во первых имплементируем обычный Input/Output
Почему не наследование?Дело в том, что наследование в некоторых случаях(90%) - антипаттерн.Не буду много городить, в двух словах, нарушение инкапсуляции и ещё несколько проблем которые появляются в следствии наследования.Делегирование в данном контексте сыпит нам лишь плюсы которые мы сейчас и увидим.
Во первых имплементируем обычный Input/Output
Java:
package ru.metafaze.io.impl;
import ru.metafaze.io.BaseInputStream;
import java.io.InputStream;
public class InputStreamImpl implements BaseInputStream {
private final InputStream input;
public InputStreamImpl(InputStream input) {
this.input = input;
}
@Override
public int readByte() {
try {
/**
* метод InputStream.read имеет сигнатуру int, но по факту читает только 1 байт
*/
return input.read();
} catch (Exception e) {
throw new RuntimeException(e);
}
}
@Override
public int readShort() {
return readByte() | readByte() << 8;
}
@Override
public int readInt() {
return readShort() | readShort() << 16;
}
}
Java:
package ru.metafaze.io.impl;
import ru.metafaze.io.BaseOutputStream;
import java.io.OutputStream;
public class OutputStreamImpl implements BaseOutputStream {
private final OutputStream output;
public OutputStreamImpl(OutputStream output) {
this.output = output;
}
@Override
public void writeByte(int value) {
try {
/**
* метод InputStream.write имеет сигнатуру int, но по факту записывает только 1 байт
*/
output.write(value);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
@Override
public void writeShort(int value) {
writeByte(value & 0xFF);
writeByte(value >> 8);
}
@Override
public void writeInt(int value) {
writeShort(value & 0xFFFF);
writeShort(value >> 16);
}
}
Ну а теперь давайте к шифрованию.
Java:
package ru.metafaze.io.impl;
import ru.metafaze.io.BaseInputStream;
public class XorInputStream implements BaseInputStream {
private static final int BYTE_MASK = 0xFF;
private static final int SHORT_MASK = 0xFFFF;
private static final int INT_MASK = 0xFFFFFFFF;
private final BaseInputStream input;
private final int xorKey;
public XorInputStream(BaseInputStream input, int xorKey) {
this.input = input;
this.xorKey = xorKey;
}
@Override
public int readByte() {
return xorAndApplyMask(input.readByte(), xorKey, BYTE_MASK);
}
@Override
public int readShort() {
return xorAndApplyMask(input.readShort(), xorKey, SHORT_MASK);
}
@Override
public int readInt() {
return xorAndApplyMask(input.readInt(), xorKey, INT_MASK);
}
private int xorAndApplyMask(int value, int xorKey, int mask) {
return (value ^ xorKey) & mask;
}
}
Java:
package ru.metafaze.io.impl;
import ru.metafaze.io.BaseOutputStream;
public class XorOutputStream implements BaseOutputStream {
private static final int BYTE_MASK = 0xFF;
private static final int SHORT_MASK = 0xFFFF;
private static final int INT_MASK = 0xFFFFFFFF;
private final BaseOutputStream output;
private final int xorKey;
public XorOutputStream(BaseOutputStream output, int xorKey) {
this.output = output;
this.xorKey = xorKey;
}
@Override
public void writeByte(int value) {
output.writeByte(value ^ xorKey);
}
@Override
public void writeShort(int value) {
output.writeShort(value ^ xorKey);
}
@Override
public void writeInt(int value) {
output.writeInt(value ^ xorKey);
}
}
Но что на счет компрессии?
Компрессия в данном контексте - частный случай делегации, либо же паттерн под названием Decorator который расширяет функционал готового класса.
Нет смысла реализовывать интерфейс по очевидным причинам.
Компрессия в данном контексте - частный случай делегации, либо же паттерн под названием Decorator который расширяет функционал готового класса.
Нет смысла реализовывать интерфейс по очевидным причинам.
Java:
package ru.metafaze.io.impl;
import ru.metafaze.io.BaseInputStream;
import ru.metafaze.io.BaseOutputStream;
public class CompressedInputStream {
private static final int BYTE_FINGERPRINT = 0b00000000;
private static final int SHORT_FINGERPRINT = 0b10000000;
private static final int INT_FINGERPRINT = 0b11000000;
private final BaseInputStream input;
public CompressedInputStream(BaseInputStream input) {
this.input = input;
}
public int readCompressedInt() {
int fingerPrint = input.readByte();
return resolveReadMethodFromFingerPrintAndRead(fingerPrint);
}
private int resolveReadMethodFromFingerPrintAndRead(int fingerPrint) {
switch (fingerPrint) {
case BYTE_FINGERPRINT -> {
return input.readByte();
}
case SHORT_FINGERPRINT -> {
return input.readShort();
}
case INT_FINGERPRINT -> {
return input.readInt();
}
default -> throw new IllegalArgumentException("unknown fingerprint " + Integer.toBinaryString(fingerPrint));
}
}
}
Java:
package ru.metafaze.io.impl;
import ru.metafaze.io.BaseOutputStream;
public class CompressedOutputStream {
private static final int BYTE_MASK = 0xFF;
private static final int SHORT_MASK = 0xFFFF;
private static final int INT_MASK = 0xFFFFFFFF;
private static final int BYTE_FINGERPRINT = 0b00000000;
private static final int SHORT_FINGERPRINT = 0b10000000;
private static final int INT_FINGERPRINT = 0b11000000;
private final BaseOutputStream output;
public CompressedOutputStream(BaseOutputStream output) {
this.output = output;
}
public void writeCompressedInt(int value) {
int fingerPrint = getFingerPrintFromValue(value);
output.writeByte(fingerPrint);
resolveWriteMethodByFingerPrintAndWriteValue(fingerPrint, value);
}
private void resolveWriteMethodByFingerPrintAndWriteValue(int fingerPrint, int value) {
switch (fingerPrint) {
case BYTE_FINGERPRINT -> output.writeByte(value);
case SHORT_FINGERPRINT -> output.writeShort(value);
case INT_FINGERPRINT -> output.writeInt(value);
}
}
private int getFingerPrintFromValue(int value) {
if ((value & BYTE_MASK) == value) {
return BYTE_FINGERPRINT;
}
if ((value & SHORT_MASK) == value) {
return SHORT_FINGERPRINT;
}
return INT_FINGERPRINT;
}
}
В целом все.Теперь посмотрим на применения нашего кода.
Java:
@Test
void globalTest() throws Exception {
int xorKey = 0xAABBCCDD;
Path filePath = Paths.get("file");
BaseOutputStream fileOutput = new OutputStreamImpl(Files.newOutputStream(filePath));
BaseOutputStream xorOutput = new XorOutputStream(fileOutput, xorKey);
CompressedOutputStream compressedOutput = new CompressedOutputStream(xorOutput);
compressedOutput.writeCompressedInt(3000);
}
Тут видны плюсы делегации(объект может становится кем угодно в этапе исполнения программы.наследование разумеется так не может)
а так же видны и наши старания - теперь каждый компонент можно применять отдельно.
Но теперь встает опять вопрос...Видов реализаций так много, будет довольно трудно поддерживать пары input/output.
Давайте это исправим добавив в нашу систему паттерн AbstractFactory.
AbstractFactory позволяет нам описать интерфейс создания объектов связанных по смыслу, но имеющих разные "наборы" реализаций.
а так же видны и наши старания - теперь каждый компонент можно применять отдельно.
Но теперь встает опять вопрос...Видов реализаций так много, будет довольно трудно поддерживать пары input/output.
Давайте это исправим добавив в нашу систему паттерн AbstractFactory.
AbstractFactory позволяет нам описать интерфейс создания объектов связанных по смыслу, но имеющих разные "наборы" реализаций.
Java:
package ru.metafaze.io;
public interface IOFactory {
BaseInputStream createInputStream();
BaseOutputStream createOutputStream();
}
Java:
package ru.metafaze.io.impl;
import ru.metafaze.io.BaseInputStream;
import ru.metafaze.io.BaseOutputStream;
import ru.metafaze.io.IOFactory;
public class XorIOFactory implements IOFactory {
private final BaseInputStream input;
private final BaseOutputStream output;
private final int xorKey;
public XorIOFactory(BaseInputStream input, BaseOutputStream output, int xorKey) {
this.input = input;
this.output = output;
this.xorKey = xorKey;
}
@Override
public BaseInputStream createInputStream() {
return new XorInputStream(input, xorKey);
}
@Override
public BaseOutputStream createOutputStream() {
return new XorOutputStream(output, xorKey);
}
}
Java:
IOFactory ioFactory = new XorIOFactory(fileInput, fileOutput, xorKey);
BaseInputStream xorInput = ioFactory.createInputStream();
BaseOutputStream xorOutput = ioFactory.createOutputStream();
Спасибо что прочитали эту огромную статью, на сегодня все.Однозначно смотивирую вас разобраться в этом самостоятельно : то что описано в статье - 1% из того всего что я изучал на счет клинкода.
Помимо этого, добавлю, что вполне где то мог ошибиться/привести неуместный пример.Принимается любая критика.
P.S ко всем классам написаны юнит тесты.Если нужно, прикреплю как метаинформацию.
Bye
Помимо этого, добавлю, что вполне где то мог ошибиться/привести неуместный пример.Принимается любая критика.
P.S ко всем классам написаны юнит тесты.Если нужно, прикреплю как метаинформацию.
Bye
Последнее редактирование: