Pull to refresh

Comments 37

Спасибо за статью, весьма интересно!
После прочтения сразу приходит на ум шаблон «Стратегия», и не могли бы вы обозначить нюансы использования описанного шаблона в сравнении со статегией с вашей точки зрения?
ну в данном случае на мой взгляд разница очевидна и значительна. Стратегия инкапсулирует поведение, а условия переключение между стратегиями как правило происходит из вне. В то время как "Правило" инкапсулирует условия и не определяет в себе поведения, что позволяет использовать одни правила для реализации ветвлений разных действий(в том числе переключать стратегии)
при определенной доле сноровки думаю вполне возможно
Можно более подробно о пункте «О чем нужно помнить?»
Конечно, данный шаблон — это ведь не жесткая схема и он может быть модифицирован в зависимости от задачи и логики, нет универсальных рекомендаций. Например, В интерфейс IRule может быть добавлен метод IsMatch(), который проверяет правила на соответствия каким-либо условиям. Пример того, когда важен порядок выполнения правил — выпадает три шестерки — очки обнуляются. В этом случае, если после этого правила посчитаются еще и очки за единички, то результат будет ошибочным, поэтому изменения коснутся логики выбора лучшего правила. И мы должны убедиться в том, что после выполнения правила обнуления никакие другие не выполняются. Другой пример того, когда существуют зависимости между правилами — например, выпала единичка, то мы не можем засчитать две пары, а если выпала тройка, то можем. Ну и так далее, в зависимости от задачи.
Позвольте мне спросить: что имеется ввиду «Знаем, что есть готовые «движковые» решения для бизнес-логики»? Какие, например? Можно ссылочкой :) Спасибо.
Имеются ввиду экспертные системы. Воспользуйтесь поисковиком.
Тема была сильно измусолена вплоть до начала 90х — всяких там rule engine'ов было выше крыши. Они и сейчас есть в наличии, но, как мне кажется, они несколько утратили в популярности из-за т.н. AI Winter. Когда-то люди надеялись, что на таких правилах можно построить сильно крутой AI, однако надежды не сбылись и про rule engines все в основном как-то позабыли. Что-то в этом духе произошло и с Lisp'ом. Как мне кажется, незаслуженно — ведь помимо AI правила имеют большое количество других применений, где они могут быть весьма эффективны.
На самом деле тема большая — логика на основе forward chaining (Rete algorithm, это всё), backward chaining (Prolog, etc.). К IRule это имеет довольно опосредованное отношение, зато имеет отношение к pattern matching'у, unification etc. Я делал подобную хрень (forward chaining) на основе трансляции правил в хранимки и триггеры PostgreSQL.
> [3,4,5,3,3] — 350 очков

Как получили? Если допустить переупорядочивание, тогда понятно:

[3,3,3,*,*] +300
[5,*,*,*,*] +50
Все верно, порядок неважен, кости кидаются одновременно.
Единственное, что нужно учесть — при реализации этого шаблона может качественно возрасти вычислительная сложность. В подавляющем большинстве случаев оно не сыграет, но тем, кто делает какие-то рефлексивные штуки или с большим количеством переборов самих по себе или с совсем сложной логикой, когда правил много и они рекурсивно зависят друг от друга — можно поймать «плюху». Экспертные системы отличаются от такой простенькой реализации в том числе тем, что позволяют сильно снизить вычислительную сложность в подобных случаях.
А нельзя ли это реализовать с помощью регулярных выражений?
А какая действительная польза этого паттерна кроме украшения кода? Сложность алгоритма ведь только возрастёт, а это печально, т.к. подобные проблемы возникают именно там где и так сложность алгоритма не маленькая…
По каким параметрам Вы оцениваете сложность алгоритма а) в случае большого дерева if- и б) в случае применения шаблона? В статье говорится о уменьшении цикломатической сложности и сложности изменения и добавления новой логики.
М… вроде как сложность алгоритма считалась как количество операций, и ежели мы их пхнё в другой класс и вызовем оттуда — добавится еще и операция вызова но явно не уменьшится их количество, только визуально…
Ну так всегда, трейдофф между скоростью и удобством.
Я ниже написал про сложность алгоритма, и в предложенном вами примере она действительно очень сильно растет.
Причем если смотреть по честному — сложность добавления нового правила совсем не сводится к написанию нового класса — надо опять же проанализировать, чтобы система работала корректно, т.к. предложенная стретегия работает только если выбор правил действительно можно осуществлять жадным алгоритмом.
Я бы еще явно определил порядок применения правил. В вашем коде порядок «по максимуму очков добавленных от применения очередного правила к оставшимся костям», и я бы упростил его до «первыми применяются правила приносящие большее кол-во очков» и задал его так:
List<Rule> rules = ...
Collections.sort(rules, sortByScoreDescending);

Но часто встречается другой порядок: «В любом порядке, главное получить наибольшее количество очков»:
Правила:
111xx — 300
11xxx — 200
221xx — 200
Вход:
11122. Ваша реализация посчитает 300 очков, но возможен вариант с 400 очками.
List<Rule> rules = ...

for(List<Rule> orderedRules : perestanovka(rules)) {
  // Посчитать для каждого варианта применения правил и выбрать вариант с самым большим score.
}

А может оказаться, что игроку разрешается выбирать из возможных вариантов. Тогда придется интерфейс этой части системы менять.

Повторюсь, я бы еще явно задал порядок применения правил.
Поддержу. В предложенном в статье варианте порядок задан неявно и, более того, неочевидно.
То есть, на мой взгляд, ещё одним требованием, кроме красоты и прочего, является умение быстро отвечать на вопрос «почему сработало правило Б, а не правило А».
Да, хорошее замечание, спасибо! Для этого и правда надо задавать явный порядок применения правил при добавлении правила «пара». Об этом я упомянул в комментарии выше.
Эта логика не даст максимальный результат если одна единица будет давать 100 очков, а три единицы 200. Тогда при выпадении трёх единиц будет получено 200 очков, а если учитывать их по одной, то можно зачислить 300 очков
Три единицы дают тысячу очков здесь. Если три единицы будут давать 200 очков, а по одной — 100, то правило не имеет смысла, и оно не нужно. Это все равно, что в покере четверка будет оцениваться меньше, чем две пары. Если писать другие, странные правила — то придется менять логику выбора лучшего правила, очевидно. И для всех случаев писать модульные тесты, чтобы избежать всех странностей.
Да и не странные правила тоже в текущую логику не ложатся, например:
112XX — 900
13XXX — 900
111XX — 1000

На комбинации 11123 — выдаст 1000 очков, хотя должно 1800.

Более того, у такого подхода (именно как в статье, а не у самого паттерна) есть фундаментальный минус: даже сходу можно предложить методику проведения тестов по правилам, за O(K^2) времени, где K — количество костей. При этом в предложенной технике будет выполняться O(N*K^2) времени, где N — число правил. Так как обычно в реальных системах надо считать не 10 правил при выбросе 5 костей, а сотни и тысячи правил на более сложных структурах — то такой подход сразу просаживает производительность.

Сам по себе паттерн, безусловно, полезен, но для его правильной реализации в большинстве случаев нужно писать еще систему, которая будет строить стратегию применения за приемлемое время.
Извините, но это ужасно. Визуально, тот код, который назывался плохим для игры в кости, выглядит лучше. Он меньше.
Да, он непонятный, но только потому, что совершенно безобразно написаны имена, тройка прыгает по коду.
Если его причесать и если класс действительно должен считать кубики и там написан минимум на данный момент (YAGNI), то он будет лучше. Но он правда и ягни не отвечает. Просто странно ни в какие ворота написан.

Паттерн здесь выглядит еще хуже.
Сразу оговорюсь, я не критикую автора вообще за паттерн, возможно он хотел показать его использование, а пример выбрал неудачный.

Но я лично, паттерны и не люблю. Паттерны появляться должны самостоятельно, если надо. Обычно, они нужны для гибкости. Но программы не должны быть гибкими. Программы должны быть точными. А это буквально наоборот, чем то, что здесь написано. Т.е. вы с чего-то думаете о каком-то вымышленном завтра, когда вдруг внезапно появятся новые правила. И наворотили бог весть что. Вывернули логику, заставили страдать компилятор.

Не воспринимайте как оскорбление, просто давайте по другому взглянем на задачу. Я всегда говорил, что язык программирования — это язык, а программирование — это перевод требований. Как есть. Без никаких домыслов о завтра. Завтра будет завтра. Если записано как есть, то и менять не очень много надо будет. Также, в коде должно быть максимальное соотношение кода, которое прямо относится к задаче и к требованиям. Их должно быть видно.

Давайте и посмотрим:
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. Порядок не важен.
2. Из всех комбинаций ищем наборы комбинаций (назову шаблонов), которые дают максимальное количество очков.
И вот код:
int GetScore(int[] roles)
{
    List<int> rolesList = roles.ToList();

    Func<int> getScoreFromBestTemplate = () =>
        {
            var templatesWithScores = new []
                {
                    new { Template = new List<int> {1}, Score = 100 },
                    new { Template = new List<int> {5}, Score = 50 },
                    new { Template = new List<int> {1, 1, 1}, Score = 1000 },
                    new { Template = new List<int> {2, 2, 2}, Score = 200 },
                    new { Template = new List<int> {3, 3, 3}, Score = 300 },
                    new { Template = new List<int> {4, 4, 4}, Score = 400 },
                    new { Template = new List<int> {5, 5, 5}, Score = 500 },
                    new { Template = new List<int> {6, 6, 6}, Score = 500 },
                };

            Func<List<int>, List<int>, List<int>> deleteMatchElements = (source, template) =>
                {
                    var sourceCopy = source.ToList();
                    template.ForEach(item => sourceCopy.Remove(item));
                    return sourceCopy;
                };

            Func<List<int>, List<int>, bool> isMatch =
                 (source, template) => deleteMatchElements(source, template).Count == source.Count - template.Count;

            var templateWithMaxScore = 
                (from tws in templatesWithScores
                    where isMatch(rolesList, tws.Template)
                    orderby tws.Score descending
                            select tws
                            ).FirstOrDefault() ?? new { Template = new List<int> {}, Score = 0 };

            rolesList = deleteMatchElements(rolesList, templateWithMaxScore.Template);

            return templateWithMaxScore.Score;                    
        };

    int sumScore = 0;
    int score = 0;
    do
    {
        score = getScoreFromBestTemplate();
        sumScore += score;
    }
    while (score != 0);

    return sumScore;
}


Согласитесь, так читаемость намного лучше. Требования видны невооруженным глазом. Не надо разбираться с конструкторами классов. Да и классы писать не надо. Буквально видно по шагам, как откуда всё получается. Не должно быть у простых задач много кода!
В нем да, жестко зашиты требования, как поняты мною на данный момент. Да, в этом коде заложено, что я выбираю всегда самый «дорогой» шаблон. Но ведь так и надо на данный момент. Именно это и требуется из задачи. Нет никакого будущего. Если меня попросили перевести на английский «собачка перебежала дорогу», то именно так и надо поступать, а не писать «ххх перебежало дорогу». Чтобы когда-то послезавтра кошку туда подставить. Когда требования выражены четко, когда кода минимум, тогда и переписать будет не проблема, если изменятся требования.

Код возможно не идеально простой. Может, заставит задуматься о замыканиях и сайд-эффекте.
Но, в общем, не в паттернах счастье.
С конструкторами разбираться не надо, согласен, но, на мой взгляд, проще разобраться с ними, чем лезть в дебри Linq и тонкости лямбда-выражений и замыканий. К тому же вышеизложенный способ реализации подойдет только для си-шарпа, он специфичен для языка. Это мое мнение.
А вообще это холивар на тему того, нужны шаблоны или нет. На мой взгляд, нужны. Это язык, который понятен всем. А ввязываться в спор я не хочу, простите. Кому надо — тому пригодится.
Спорить не зачем и не о чем. Просто пример для этого паттерна неудачный.

Паттерны сами по себе в отрыве от задачи — зло. Я не знаю полезных паттернов. Каждый из них что-то плохое, да и делает.

Вы во вступлении написали, что бывает надо писать много ветвлений, а этот паттерн позволяет избавиться. Если в отрыве от задач, то стремиться избавиться от ифов — это просто объявить войну компилятору и языку программирования. Это добавление нового измерения, выворачивание условий наизнанку и создание своего «настраиваемого» языка. Т.е. есть в C# конструкция if, но кому-то не нравится этот язык и он решает создать свой. Более гибкое и настраиваемое решение — это всегда более ущербное и ненадежное.

Но все что мы пишем, с паттернами или без — зло. Любая новая строчка кода — увеличение энтропии. Поэтому этот паттерн может быть полезен, как меньшее зло. Можно представить, например, что будет стоять задача — пользователь в графическом интерфейсе задает правила поведения системы. Вот, скорее всего, такой паттерн будет хорошо оправдан.

Вот так я отношусь к паттернам. И еще, они и сами появляются, если заниматься постоянным рефакторингом.
Да, пожалуй, Вы в чем-то правы. Но что будет, если в Ваш код добавлять условия для пар? то есть 11 22 3, 22 33 4 и так далее? Можно ли там будет написать не Template, а именно логику для определения пары, и прочих правил которых может быть несколько?
Не совсем понял. В моем коде в шаблоне набираете цифры в любом порядке, любые цифры, даже не одинаковые.
Т.е. правило:
1, 1, 2, 2, 3 — xxx очков? (350 очков)
Просто добавляете строку:
 new { Template = new List<int> {1, 1, 2, 2, 3 }, Score = 350 }

Можете поменять порядок цифр, смысл не меняется.
Так получился у меня код, потому что я следую определенной стратегии. Представляю, что требования имеют смысл. И я этот смысл перевожу на язык программирования. Как можно точнее и как можно с меньшими своими домыслами. Я и не думал особо.

Посмотрев на
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..6. За каждое правило начисляем очки.
Правила бывают с одним значением и с тремя.
Правила:
если выпадает 1-ца один раз, то начисляем 100;

если выпадает 4-ка три раза, то начисляем 400;
… „
То в таком виде стратегия перевода требования заставила бы создавать правила именно так. Но далее, в процессе рефакторинга, не применяя никаких паттернов, скорее всего алгоритм подсчета был бы вынес в один метод, данные, которые ему передаются, тоже сведены в список. Далее, по сути, создание правил — это всего лишь конструктор для этих списков. В конце концов, из-за рефакторинга снова пришли бы где-то к такому же коду.

И наоборот. Если я написал код вот так, как сейчас, при нынешних требованиях, но вдруг начали приходить “неудобные» требования. Например: «За 500 выпадений 6-рок, начисляем миллион». То теперь, становится ясно, что писать 500 раз 6 как минимум неудобно. Тогда добавляется конструктор. А именно: в коде анонимный тип заменяется на класс, в нем эти же два свойства, и добавляем конструктор, которому передаем (int diceRollNumber, int count). А сам алгоритм не меняется.

Как видим, снова пришли к тому же коду.
Зря вы не любите linq. Сильно упрощает жизнь. Читается легко, если привыкнуть.

Этот код я написал для примера и на Func. Если не нравится такой подход, код можно без проблем разбросать на несколько методов в классе, вместо анонимного типа написать небольшой класс с двумя свойствами. Код останется практически таким же.

select я бы не выбрасывал. Но он также преобразовывается в foreach. И будет обычный ООП код.

Простота этого кода и прямое решение задачи, без паттернов, имеет еще один плюс: если присмотреться, то этот подход даже гибче, чем с паттерном. Тут нет никаких дополнительных предположений, кроме того, что я понял из требований. Тут нет отдельного класса для правила из одного элемента. Тут не зашито утверждение, что в правиле должны быть только одинаковые цифры. Поэтому теперь добавлять новые правила вообще без проблем, даже если они изменятся и это будет проще, чем с паттерном. Т.е. это решение гибче. Хотя я к ней никогда обычно не стремлюсь.

А гибким оно получилось потому, что как раз я посмотрел на требования и не додумывал ничего от себя. Я не искал в правилах игры неявные закономерности и не вшивал их в код.
Ваш код даже в 4:30 ночи понятен в отличие от кода автора статьи. Опять же не в обиду последнему.
Sign up to leave a comment.

Articles