- 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: Дублирование кода. Если дерево условий разветвлено, то порой нельзя избавиться от ситуации, когда один и тот же код присутствует в нескольких ветках.

Тут и приходит на помощь шаблон «Правила». Его структура очень проста:

Uml-диаграмма структуры

Множественные ветвления и шаблон «Правила»

Здесь класс 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-м классе (естественно, не снабдив наш плохой код комментариями — кому они нужны!)

Плохой, негодный класс Game

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, который нужен для оценки количества очков за определенный набор кубиков.

IRule.cs

public interface IRule
{
  ScoreResult Eval(int[] dice);
}

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

RuleSet.cs

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. Конечно, небольшой класс-помощник

ScoreResult.cs

public class ScoreResult
{
  //результат подсчета очков
  public int Score {get;set;}

  //какие кубики были использованы (чтобы кубик не участвовал в оценке другими правилами)
  public int[] DiceUsed {get;set;}

  //какое правило было использовано, чтобы определить, какое правило было лучшим (в методе BestRule)
  public IRule RuleUsed {get;set;}
}

4. И определим сами правила.

ConcreteRules.cs

//правило для одного кубика
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, он не будет содержать почти ничего, кроме логики добавления правил и логики подсчета очков

Game.cs - Evaluator

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)

Концепция 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/