Движение вверх и вниз
Движение вверх и вниз

В современном обучении программированию, как правило, основное внимание уделяется парадигме объектно-ориентированного программирования (OOP) и вытекающей из неё методологии объектно-ориентированного проектирования (OOD). Определённый ренессанс в наше время испытывает парадигма функционального программирования (FP), но практически никогда в связке с ней не рассматривается функциональное проектирование (FD). Попытаемся осветить наше видение этих вопросов.

Замечание о термине "функциональное"

Сразу же определимся с терминологией. Некоторые педантичные читатели, возможно, захотят сделать замечание, что те функции, которые имеются в виду в "функциональном проектировании" (то есть задачи системы) – это не те функции, которые имеются в виду в "функциональном программировании" (то есть формализм лямбда-выражений). На наш взгляд, напротив, речь здесь идёт о глубоко взаимосвязанных между собой вещах, а именно о денотационной семантике программного кода, с одной стороны, и его синтаксисе, с другой. Для целей нашего изложения примем, что функциональные требования к программе – это и есть денотационная семантика, которой программа должна обладать, либо же её часть (так как семантика программы может быть не полностью специфицирована в требованиях).

Объектно-ориентированное проектирование

Методология объектно-ориентированного проектирования, опирающаяся на парадигму объектно-ориентированного программирования, сводится вот к чему. Мы подвергаем проектируемую систему декомпозиции, то есть логически представляем её в виде суммы отдельных частей, взаимодействующих между собой. Считается кошерным, если эта декомпозиция производится не абы как, а по определённым шаблонам, а именно с помощью описанных в литературе нескольких десятков паттернов проектирования (точное количество и состав паттернов отличается в зависимости от конкретной школы OOD). В ряде школ OOD требуется, чтобы деление на классы соответствовало физической структуре предметной области.

В конце концов мы должны прийти путём декомпозиции к простейшим объектам, каждый из которых выполняет только одну конкретную функцию, уникальную для нашей задачи. Когда мы смогли декомпозировать задачу на такие элементарные объекты, то дальше каждый из них представляется объектом класса в смысле OOP, и внутри методы этого класса программируются как бог на душу положит, то есть императивными последовательностями операторов. Особых разъяснений о том, что именно делать внутри классов, OOD не подразумевает.

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

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

В целом, ключевой проблемой OOD и используемого им метода функциональной декомпозиции является игнорируемое этой методологией свойство сложной системы не сводиться к сумме своих частей.

Функциональное проектирование

Функциональное проектирование происходит в точности противоположным образом. Мы сосредотачиваемся на стратегии, которая будет наполнением нашей высокоуровневой функции, и думаем о том, как построить эту стратегию из элементарных кирпичиков, представляющих собой стандартные элементы языка программирования (условно говоря, элементарные функции). Если этих элементарных функций нам не хватает (как обычно и происходит), мы комбинируем их между собой, синтезируя функции более высокого уровня, представляющие собой семантическую абстракцию более высокого порядка, чем наш исходный язык программирования, часто называемую DSL (domain-specific language), то есть предметно-ориентированный язык. Рано или поздно мы поднимаемся в нашей метаязыковой абстракции до того уровня, когда конструкции DSL позволяют нам описать нашу стратегию в одной или нескольких функциях верхнего уровня.

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

Вместо декомпозиции в данном случае в качестве основной методики проектирования применяется семантическая абстракция, то есть синтез.

Некоторые авторы рассматривают FD как специфический вид декомпозиции – функциональную декомпозицию:

Функциональная декомпозиция в представлении некоторых авторов
Функциональная декомпозиция в представлении некоторых авторов

Мы считаем это неверным. Функции невозможно декомпозировать в строгом смысле из-за наличия системного эффекта. Туловище, руки, ноги и голова способны совместно выполнять функции человека только в морге при прощании, да и по отдельности друг от друга не ходят и не говорят.

Методы программирования сверху-вниз и снизу-вверх

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

Хотя зачастую программирование на функциональных языках тяготеет к методу снизу-вверх благодаря удобству работы с отдельными функциями в цикле интерпретатора REPL, но то же самое можно сказать и о таком классическом объектном языке, как Smalltalk.

Пример с хабра

В одном комментарии на хабре мы почерпнули хорошую иллюстрацию сказанного. Представим, что мы ничего не знаем о написании шахматных программ и хотим методами OOD реализовать такую шахматную программу. Тогда мы выделим объекты в виде шахматной доски и 32 фигур (которые очень удачно сводятся всего к шести классам), релизуем у фигур свойство "цвет" и методы "ход" и "взятие"... и всё. И дальше совершенно непонятно, чьим, собственно, методом должна быть стратегия игры. На практике, наверное, она в таком случае будет реализована в виде методов доски, реализуя антипаттерн "суперкласс", и весь OOD на этом закончится.

Именно таким объектным образом, однако, реализуется тактика юнитов в классических стратегических играх вроде Warcraft. Каждый юнит воюет сам за себя. Это нормально и даже полезно для непритязательного ИИ игры, который может быть обыгран не очень продвинутым игроком, но обычная война, как легко заметить, происходит совсем не так. Реальные военнослужащие в успешно воюющей армии в основном исполняют планы командиров, а не занимаются ситуационным реагированием каждый за себя (и, кстати, не являются клонами друг друга).

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

Социальные проблемы функционального проектирования

Функциональное проектирование, с другой стороны, зачастую приводит к труднообъяснимому поведению программ при реализации целевой стратегии. Это само по себе является реальной социальной проблемой символического искусственного интеллекта, построенного по функциональным принципам – он может генерировать решения, которые кажутся людям противоестественными, хотя на практике работают.

Поучительно будет привести следующую историю.

В начале 1980-х годов Дуглас Ленат из Техаса разработал программу EURISCO, написанную на языке Лисп и использовавшую глубокую интроспекцию для модификации своего собственного кода с целью реализации искусственного интеллекта. EURISCO автоматически выдвигала различные предположения (эвристики) о том, как наиболее эффективно решать поставленную задачу, и пыталась применить их на практике. Если эвристика давала хороший результат, то она оставалась в коде и развивалась дальше, если плохой – то удалялась из кода программы (нечто подобное популярно описал Пелевин в "iPhuck 10"). Ленат с EURISCO участвовал в американском чемпионате по военно-морской игре Traveller TCS и выиграл чемпионат в 1981 году. Тогда правила игры изменили, но EURISCO снова выиграла чемпионат в 1982 году, после чего Лената забанили. Суть военно-морской стратегии EURISCO заключалась в том, чтобы не строить большие корабли, а сделать москитный флот из маленьких слабых корабликов, которые, однако, крайне сложно уничтожить во всей совокупности. В 1980-х над этой стратегией посмеялось и игровое сообщество, и профессиональные военные, как над неким артефактом несовершенства правил игры. А сейчас мы видим, как она применяется в реальной жизни в войне воздушных и морских дронов. Не прошло и 50 лет, как люди дозрели до идей искусственного интеллекта, работавшего на машине примерно с 256 килобайтами памяти и процессором примерно на 1 МГц.

Ленат умер 31 августа 2023 года, вероятно успев увидеть торжество идей своей программы.

Выводы

Программируете с использованием тех инструментов, которыми владеете.

Если вы владеете как объектно-ориентированной, так и функциональной парадигмой, оценивайте характер сложности своей задачи. Если сложность тактическая, используйте OOD и выполняйте декомпозицию. Если сложность стратегическая, используйте FD и стройте DSL. Если завал везде, попробуйте FD на верхнем уровне и OOD на нижнем.

Комментарии (30)


  1. BobovorTheCommentBeast
    17.06.2025 13:20

    Ну в смысле методов доски. Доска просто фигуры держит на себе.

    Ходы должен производить отдельный объект. А глобально игру (выигрыш, проигрыш) поддерживать другой, запрашивать ничью третий. И не будет никаких супер классов и в шашки можно будет легко сыграть.

    Пример так себе. Проблема всех функциональных статей имхо - слишком вырожденные примеры.


    1. vadimr Автор
      17.06.2025 13:20

      Вот именно иллюзия, что можно легко унаследовать шашки от шахмат, и составляет проблему OOD.


      1. SergeyEgorov
        17.06.2025 13:20

        Зачем класс доска? Что он делает? Зачем шесть классов фигур?


        1. vadimr Автор
          17.06.2025 13:20

          Ну а как бы вы объектно декомпозировали шахматы?

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


          1. cockrider5054
            17.06.2025 13:20

            class AbstractFigure

            sealed class ChessFigure<TChessMoveData> : AbstractFigure where TChessMoveData : struct, IChessMoveData

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


            1. vadimr Автор
              17.06.2025 13:20

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


          1. SergeyEgorov
            17.06.2025 13:20

            Я бы начал с чего-то вот такого пожалуй:

            public class UnitTest1
            {
                [Fact]
                public void Test1()
                {
                    ChessGame game = new ChessGame();
            
                    MightyPlayer player = new MightyPlayer(game);
            
                    Move move = player.getNextMove();
            
                    Piece piece = move.getPiece();
            
                    Assert.Equals(Piece.Pawn, piece.getName());
                    Assert.Equals(Color.White, piece.getColor());
            
                    Square squareFrom = move.getSquareFrom();
            
                    Assert.Equals(LetterAxis.E, squareFrom.getLetter());
                    Assert.Equals(NumericAxis.2, squareFrom.getNumber());
            
                    Square squareTo = move.getSquareTo();
            
                    Assert.Equals(LetterAxis.E, squareTo.getLetter());
                    Assert.Equals(NumericAxis.4, squareTo.getNumber());
            
                }
            }


          1. skovoroad
            17.06.2025 13:20

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

            Но нет ничего невероятного, то в шахматах, написанных с использованием ООП, будут:

            • примитивный класс хранения состояния игры (доска), просто сохраняет инварианты, к примеру, не позволяет поставить две фигуры на одну клетку.

            • наверное, при реализации логики понадобится возможность итерироваться по дереву возможных вариантов ходов. Это удобно реализовать в виде класса, ведь это же просто контейнер, дерево положений доски со своими инвариантами. И, возможно, с какими-то дополнительными промежуточными данными, которые можно привязать к узлам.

            • Наверняка есть какие-то вспомогательные абстракции - например, оценка позиции. И наверняка их методики бывают разные, и у них есть состояние (например, кэш предыдущих рассуждений), поэтому они кандидаты на общий интерфейс и реализация в классах.

            • и, допустим, самый корневой класс, принимающий решение за игрока, строящий логику выбора следующего хода. Он через DI принимает предыдущие.

            • ну и так далее, нужна постановка задачи, это всё вольная фантазия, дело не в моих выдумках, а в том...

            А в том, что вы сами выдумали себе какой-то свой ужасный ООП, в котором программисты моделируют не объекты предметной области, а объекты материального мира: класс для доски, потом класс для клетки, для белого и чёрного цвета, для пешек и ладей - и теперь с этим успешно боретесь.

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


            1. vadimr Автор
              17.06.2025 13:20

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

              К этому, по крайней мере, есть формальное основание в виде OOD принципа повторного использования кода.

              нужна постановка задачи

              Чего принципиально не хватает в постановке задачи "написать программу, выигрывающую в шахматы"?


              1. skovoroad
                17.06.2025 13:20

                Чего принципиально не хватает в постановке задачи "написать программу, выигрывающую в шахматы"?

                Принципиально то, что я не знаю хороших практик решения этой задачи, я никогда этим не занимался. Понятно, что надо как-то в глубину просчитывать ветви ходов и оценивать полученные позиции. Так ли это, может я что-то упускаю? Как отсекать? Как оценивать? Возможно, для оценки нужны ещё какие-то вспомогательные абстракции? Может быть, есть ещё какие-то стадии решения? Всего этого я не знаю. Нужна постановка задачи от эксперта.

                И такая постановка позволит лучше декомпозировать решение. В том числе и в ООП-парадигме, почему нет.

                А если предположить, что наш шахматный движок имеет какую-то степень абстракции и позволит дёшево реализовывать другие игры со схожим флоу, то ООП станет основной практикой. Реализовал, грубо говоря, интерфейс "правила игры" и "оценка позиции" для шашек - и ура, пользуйся, все эти доски и алгоритмы принятия решения будут автоматически переиспользованы.

                Короче, ничего ужасного в уместном применении ООП нет. Если с ума не сходить.


                1. vadimr Автор
                  17.06.2025 13:20

                  Если вы знаете хорошие практики, то вам вообще не нужно проектирование, как таковое - можно сразу начинать с разработки.

                  Это, кстати, ещё один момент из общеинженерного знания, о котором обычно не пишут ITшные инфоцыгане.


      1. BobovorTheCommentBeast
        17.06.2025 13:20

        Зачем мне наследовать шашки от шахмат? Я оставлю доску, оставлю даже фигуры. Просто подменю "контроллер игры". Который будет игнорировать "тип" фигуры и который я действительно унаследую от базового контроллера игры.

        Вообще игры это вот прям реально плохой пример. В игры ООП слишком хорошо залезает. Я читал срачи, что ООП плохо там, где тяжело выделить предметный объект, но есть области где объекты сами просятся.


  1. OlegZH
    17.06.2025 13:20

    Если хочется понять, что такое функциональное программирование (настоящее функциональное программирование — функциональное программирование в подлинном значении этого слова), то надо обратиться к рекурсии и, вообще, к рекурсивному языку описания вычислений.

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

    Мало того, что мы буквально всё воспринимаем как функцию, то есть — отображение из одного множества значений в другое, мы и самоё вычисление (выбор) искомого значения воспринимаем как процесс. Можно представить себе нить, в которой последовательно формируются произведения чисел натурального ряда. А ещё и процесс постановки, в том числе, символической (где, собственно, лямбда-исчисление и возникает)!

    Чтобы встать на функциональную точку зрения, надо представить, что каждый объект — это некоторое отображение. У этого взгляда на вещи ест две стороны. Пассивная часть заключается в том, что объект всегда представляется своим значением. А активная часть заключается в том, что с каждым объектом связано некоторое вычисление, то есть — некоторый программный код, который может быть выполнен.

    Ближе всего к этой концепции находятся клеточные автоматы и игра "Жизнь".

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


  1. OlegZH
    17.06.2025 13:20

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

    Почему? Вот это, уж, совершенно не понятно. Браузер осуществляет отображение данных в его низкоуровневом машинном представлении в его высокоуровневое пользовательское представление.


    1. vadimr Автор
      17.06.2025 13:20

      Ну да. У него нет стратегии, только тактика в виде элементов контента и их методов. Скажешь ему нарисовать кнопку - он рисует кнопку. Не его дело, зачем.

      Можно сказать, что семантика браузера задана чисто операционно.

      Ну, можно, конечно, сказать, что элементом стратегии браузера является блокирование всплывающих окон, но это мелочи по отношению к общему объёму его работы.


      1. OlegZH
        17.06.2025 13:20

        Что, значит, стратегия? В каком смысле?


        1. vadimr Автор
          17.06.2025 13:20

          В традиционном смысле: общая цель действий программы от старта до финиша. Ни один интерфейс по определению не имеет стратегии.


          1. OlegZH
            17.06.2025 13:20

            Тут, скорее, проблема отсутствия старта и финиша: где — старт и где — финиш? А так... Один что-то предъявляет, другой должен без ошибок дойти до... финиша (каждой отдельной процедуры). Как-то так.


            1. vadimr Автор
              17.06.2025 13:20

              Без ошибок дойти до финиша – это тактика. А на уровне спецификации – операционная семантика.


  1. Alex283
    17.06.2025 13:20

    В ООП берем объект "суп", который состоит из свойств "ингридиенты супа" и методов - способ его приготовления. Берем ФП есть функции "нагреть", "перемешать", "добавить", "вылить в унитаз". Отсюда простой вывод в ООП вы берете объект и пользуетсь. В ФП вы должны знать все, как пользоваться, в каком порядке выполнять действия и так далее. Иными словами, чтобы воспользоваться трудами ФП, вы создаете ООП оболочку над ним и пользуетесь


    1. vadimr Автор
      17.06.2025 13:20

      В вашей программе вся логика фактически находится в методе "сварить" суперкласса "суп". Поэтому с ООП тут не очень.

      Более тонкой проблемой является то, что в таком случае надо полностью определиться с рецептом до начала компиляции супа (или, во всяком случае, до начала варки).


      1. Alex283
        17.06.2025 13:20

        суперкласс - это "блюдо" и абстрактный метод "приготовить"; дочерние классы знают как себя приготовить


        1. vadimr Автор
          17.06.2025 13:20

          В задаче приготовления конкретного супа абстрактный класс вам не поможет никак.

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


  1. Soulskill
    17.06.2025 13:20

    Мои пять копеек. А что если парадигмы это такая же абстракция как и язык программирования. Если у нас так много языков, значит есть свои задачи. ООП это про контекст выполнения инструкций. Функциональщина это последовательность инструкций. Есть подходы где рулит функциональность, контекст у всей программы общий (змейка шахматы и ТД). Есть ООП, где важна изоляция данных, в плане читаемости. Можно конечно передавать в функции сам контекст (в виде структуры например ) но тогда чем это отличается от ООП с this, self etc.

    Мимо вообще не прогер


    1. vadimr Автор
      17.06.2025 13:20

      На мой взгляд, это разумная мысль, которая перекликается с написанным в статье.


  1. kmatveev
    17.06.2025 13:20

    При всём уважении к автору, я ничего не понял.


  1. pepperoniii
    17.06.2025 13:20

    Очень крутая статья, спасибо!


  1. itstranger
    17.06.2025 13:20

    Статья хорошая, но кому лень читать, советую сразу перейти в конец к Выводам. Там кратко написана база.


  1. izibrizi2
    17.06.2025 13:20

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


    1. OlegZH
      17.06.2025 13:20

      А где они ООП-языки? SmallTalk? Simula? Oberon?