- PVSM.RU - https://www.pvsm.ru -
Здравствуйте, уважаемые хабрачитатели. В этой статье я хотел бы поделиться знаниями об одном небольшом и простом, но полезном шаблоне, про который обычно не пишут в книжках (возможно, потому, что он является частным случаем шаблона «Команда»). Это шаблон «Правила» (Rules Pattern). Вероятно, для многих он будет очень знакомым, но кому-то будет интересно с ним познакомиться.
Очень часто при разработке сложной логики возникает дерево вложенных if-ов, которое может выглядеть, например, так:
public double CalculateSomething(Condition condition)
{
//выполняется первое условие
if(condition.First...) ...
//выполняется второе условие
if(condition.Second...) ...
//специальное условие номер один
if(condition.AnotherFirst...)
{
//но при этом выполняется первое условие
if(condition.First) ...
else...
}
//специальное условие номер два
if(condition.AnotherSecond...)
{
//но при этом выполняется второе условие
if(condition.Second) ...
else...
}
//и еще одно добавим
if(condition.YetAnotherFirst)
{
//...
if(condition.AnotherFirst && condition.Second) ...
else
{
...
}
}
// O_o
}
Знакомо? Итак, какие тут встречаются проблемы?
Проблема 1: Растущая цикломатическая сложность. Если говорить просто, то цикломатическая сложность — это глубина вложенности if-ов и циклов c учетом логических операторов. Инструменты анализа кода позволяют оценить этот параметр для всех участков кода. Считается [1], что параметр цикломатической сложности для отдельного участка кода не должен превышать 10. Из этой проблемы растет следующая.
Проблема 2: Добавление новой логики. С течением времени и добавлением новых условий становится сложно понять, куда именно добавлять новую логику и как.
Проблема 3: Дублирование кода. Если дерево условий разветвлено, то порой нельзя избавиться от ситуации, когда один и тот же код присутствует в нескольких ветках.
Тут и приходит на помощь шаблон «Правила». Его структура очень проста:
Здесь класс Evaluator содержит коллекцию реализаций интерфейса IRule. Evaluator выполняет правила и решает, какое правило надо использовать для получения результата. Чтобы понять, как это работает и выглядит в коде, рассмотрим небольшой пример на C#.
Правила игры:
Игрок кидает одновременно 5 кубиков, и в зависимости от их комбинации получает определенное количество очков.
Комбинации могут быть следующими:
1 X X X X
— 100 очков
5 X X X X
— 50 очков
1 1 1 X X
— 1000 очков
2 2 2 X X
— 200 очков
3 3 3 X X
— 300 очков
4 4 4 X X
— 400 очков
5 5 5 X X
— 500 очков
6 6 6 X X
— 600 очков
Примеры комбинаций:
[1,1,1,5,1]
— 1150 очков
[2,3,4,6,2]
— 0 очков
[3,4,5,3,3]
— 350 очков
Конечно все они могут быть и другими, и их может быть гораздо больше, но об этом позже.
Попробуем описать логику игры без применения шаблона «Правила», так, как бы мы писали на уроке информатики в 8-м классе (естественно, не снабдив наш плохой код комментариями — кому они нужны!)
public class Game
{
public int Score(int[] roles)
{
int score = 0;
for(int i=1; i<7; i++)
{
int count = CountDiceWithValue(roles, i);
count = ScoreSetOfN(count, GetSetSize(i), SetSetScore(i), ref score);
score += count * GetSingleDieScore(i);
}
return score
}
private int GetSingleDieScore(int val)
{
if(val==1) return 100;
if(val==5) return 50;
return 0;
}
private int GetSetScore(int val)
{
if(val==1) return 1000;
return val*100;
}
private int GetSetSize(int val)
{
return 3;
}
private int ScoreSetOfN(int count, int setSize, int setScore, ref int score)
{
if(count>=setSize)
{
score += setScore;
return count - 3;
}
return count;
}
private int CountDiceWithValue(int[] roles, int val)
{
int count = 0;
foreach (int r in roles)
{
if (r == val) count++;
}
return count;
}
}
Вроде бы 50 строк кода это очень мало. Но что будет, если правила игры будут изменяться и добавляться?
Например, мы добавим правила для различных комбинаций кубиков:
1 1 1 1 X
— 2000
1 1 1 1 1
— 4000
1 2 3 4 5
— 8000
2 3 4 5 6
— 8000
A A B B X
— 4000
и так далее.
В этом случае код рискует превратиться в очень запутанный. Чтобы этого избежать, перепишем код с использованием шаблона «Правила».
(Здесь мне также стоило бы сказать о том, что до рефакторинга надо покрыть все случаи модульными тестами, побурчать об их важности и необходимости для рефакторинга кода)
1. Определим интерфейс IRule
с методом Eval
, который нужен для оценки количества очков за определенный набор кубиков.
public interface IRule
{
ScoreResult Eval(int[] dice);
}
2. Создадим класс RuleSet
, который будет определять набор правил, логику для добавления правила и логику выбора лучшего из правил, которое можно применить к данному набору кубиков:
public class RuleSet
{
//коллекция правил
private List<IRlue> _rules = new List<IRule>();
//добавление правила
public void Add(IRule rule)
{
_rules.Add(rule);
}
//оценка лучшего правила - того, которое возвращает максимальное количество очков
public IRule BestRule(int[] dice)
{
ScoreResult bestResult = new ScoreResult();
foreach(var rule in _rules)
{
var result = rule.Eval(dice);
if(result.Score > bestResult.Score)
{
bestResult = result;
}
return bestResult.RuleUsed;
}
}
}
3. Конечно, небольшой класс-помощник
public class ScoreResult
{
//результат подсчета очков
public int Score {get;set;}
//какие кубики были использованы (чтобы кубик не участвовал в оценке другими правилами)
public int[] DiceUsed {get;set;}
//какое правило было использовано, чтобы определить, какое правило было лучшим (в методе BestRule)
public IRule RuleUsed {get;set;}
}
4. И определим сами правила.
//правило для одного кубика
public class SingleDieRule : IRule
{
private readonly int _value;
private readonly int _score;
public SingleDieRule(int dieValue, int score)
{
_dieValue = dieValue,
_score = score
}
//переопределенный метод интерфейса - оценка очков для набора кубиков
public ScoreResult Eval(int[] dice)
{
//класс-помощник
var result = new ScoreResult();
//использованные в оценке кубики (кубики с номерами очков) - для дальнейшего исключения
result.DiceUsed = dice.Where(d=>d == dieValue).ToArray();
//логика подсчета очков
result.Score = result.DiceUsed.Count() * _score;
//использованное правило - для определения лучшего правила по очкам
result.RuleUsed = this;
return result;
}
}
//другие правила в том же духе
5. В нашем случае классом Evaluator
со схемы будет класс Game
, он не будет содержать почти ничего, кроме логики добавления правил и логики подсчета очков
public class Game
{
private readonly RuleSet _ruleSet = new RuleSet();
public Game(bool useAllRules)
{
//старые правила
_ruleSet.Add(new SingleDieRule(1,100));
_ruleSet.Add(new SingleDieRule(5,50));
_ruleSet.Add(new TripleDieRule(1,1000));
for(int i=2; i<7; i++)
{
_ruleSet.Add(new TripleDieRule(i, i*100));
}
//дополнительные правила
if(useAllRules)
{
_ruleSet.Add(new FourOfADieRule(1,2000));
_ruleSet.Add(new SetOfADieRule(5,1,4000));
_ruleSet.Add(new StraightRule(8000));
_ruleSet.Add(new TwoPairsRule(6000));
for(int i=2; i<7; i++)
{
_ruleSet.Add(new FourOfADieRule(i,i*200));
_ruleSet.Add(new SetOfADieRule(i,i*400));
//...
}
}
}
//Пользователь может добавлять к игре свои правила
public void AddScoringRule(IRule rule)
{
_ruleSet.Add(rule);
}
//подсчет очков
public int Score(int[] dice)
{
int score = 0;
var remainingDice = new List<int>(dice);
var bestRule = _ruleSet.BestRule(remainingDice.ToArray());
//проходим по правилам последовательно с выбором лучшего и удалением кубиков с подсчитанными очками
while(bestRule!=null)
{
var result = bestRule.Eval(remainingDice.ToArray());
foreach(var die in result.DiceUsed)
{
remainingDice.Remove(die);
}
score+=result.Score;
bestRule = _ruleSet.BestRule(remainingDice.ToArray());
}
return score;
}
}
Ура! Задача решена! Теперь каждый класс занимается тем, что ему положено, цикломатическая сложность не растет,
а новые правила добавляются легко и просто. Выбор правила теперь осуществляется при помощи класса RuleSet
, содержащего набор правил, а добавление правил и подсчет очков — классом Game
.
При проектировании программы, содержащей логику, основанную на правилах, полезно иметь ввиду следующие вопросы:
— Следует ли правилам быть read-only в отношении системы, чтобы не изменять ее состояние?
— Должны ли быть зависимости между правилами? Стоит ли уделить внимание порядку выполнения правил, в случае, когда одно правило может требовать результат работы другого правила для работы.
— Должны ли порядок выполнения правил быть строго определенным?
— Должны ли быть приоритеты в выполнении правил?
— Стоит ли позволять конечным пользователям редактировать правила?
и многие другие.
Концепция Business Rules Engines очень близка к идее шаблона «Правила» — это системы, которые позволяют
определять системы правил для бизнес-логики. Обычно они имеют некий графический интерфейс и позволяют пользователям определять правила и иерархии правил, которые могут храниться в базе данных или файловой системе. В частности, данный функционал имеет и Workflow Foundation от Microsoft.
1) Используем шаблон «правила», когда надо избавиться от сложности условий и ветвлений
2) Помещаем логику каждого правила и его эффекты в свои классы
3) Отделяем выбор и обработку правил в отдельный класс — Evaluator
4) Знаем, что есть готовые «движковые» решения для бизнес-логики
Большое спасибо за внимание, надеюсь, моя творческая переработка данного учебного материала кому-нибудь поможет.
* Источником вдохновения для данной статьи послужил урок «Rules Pattern» из курса «Design Patterns» сайта pluralsight.com от Стива Смита [2]
Автор: NeoNN
Источник [3]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/programmirovanie/33859
Ссылки в тексте:
[1] Считается: http://www.mccabe.com/pdf/mccabe-nist235r.pdf
[2] Стива Смита: http://pluralsight.com/training/Authors/Details/steve-smith
[3] Источник: http://habrahabr.ru/post/179069/
Нажмите здесь для печати.