Skip to content

Classic OOP Composition Declarative

Vitaly Kamiansky edited this page Oct 31, 2018 · 3 revisions

Острый вопрос (часть вторая): Композиция как декларативный подход в чистом ООП, DSL и суть декларативности

DSL – значит декларативный?

В общем случае, предметно-ориентированный язык (DSL) не обязан быть декларативным. Он просто решает задачи из конкретной предметной области, а не из широкого спектра областей, как языки общего назначения, вроде C# или F#.

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

Но это просто забавное наблюдение. На практике DSL – язык для задач из узкой предметной области, который может быть даже императивным.

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

Ещё раз, что даёт декларативный подход?

В декларативном подходе мы описываем что хотим сделать, не описывая как это должно быть сделано.

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

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

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

И как отличить язык задачи от языка решения?

Да, здесь есть нюанс: "задача" и "решение" – понятия относительные. То, что мы рассматриваем, как задачу, на более высоком уровне абстракции будет частью какого-то решения.

Получается, что если мы представим множество задач A и множество задач B, то из A в B можно провести стрелку отношения (назовём её "сводится к решению" или "уровень выше или равен"), эта стрелка будет означать, что для любой задачи a из A есть такое подмножество B' в B, что решение всех задач из B' будет достаточным для решения a. Например, решение задачи "записать данные в файл" сводится к решению задач "открыть поток вывода", "записать массив байтов в поток вывода", "закрыть поток вывода".

Тут и композиция стрелочек работает, ведь если A сводится к решению B и B сводится к решению С, то A сводится к решению С. И помимо ассоциативности (скобки можно расставить любым образом), тут ещё будет по стрелочке из каждого множества к самому себе, так как для решения любой задачи a из A достаточно, чтобы была решена a. Вот такая категория уровней абстракции задачи. У нас между объектами (множествами задач) не больше одной стрелки, так что мы имеем дело с частичным порядком. И в этом частичном порядке стрелочки будут соединять уровни абстракции задач одной предметной области.

Всё, как в жизни: вот у нас есть разные уровни абстракции – на одном считается оклад, премия и налоговые отчисления, а на другом – сколько раз мы можем сходить на балет. Если продолжить эту историю и представить, что есть некий язык L, то он будет предметно-специфичным (языком описания задач) уровня A, если каждое слово l из L будет участвовать в описании хотя бы одной задачи a из A. Словарь языка можно расширять по мере развития языка, но лишних слов быть не должно. Лишние слова: "ставка налога" на уровне билетного подсчёта – слишком детально, и наоборот, "цена билета" на уровне бухгалтерии – нерелевантно, она из задачи уровнем выше.

Получается, что один и тот же язык (например, DSL) может быть языком задачи, языком решения или вообще чуждым языком на разных уровнях абстракции задачи. А нам для декларативности нужно, чтобы он был именно языком задачи.

Ну и как это работает в программировании?

Посмотрим на язык XAML. Аналитик говорит: нужно окно с кнопкой по центру, чтобы кнопка выполняла действие 1.

<Window x:Class="XamlApp.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:XamlApp"
        mc:Ignorable="d"
        Title="Main Window" Height="350" Width="525">
    <Grid x:Name="grid1">
        <Button x:Name="button1"  Width="100" Height="30" Content="Кнопка" Command="{Binding PerformAction1Command}" />
    </Grid>
</Window>

XAML, как внешний DSL, позволяет дизайнеру описать это самое окно, как показано выше, пока разработчик на языке общего назначения C# реализует команду 1. Если присмотреться внимательнее, то для дизайнера предметно ориентированный язык будет состоять из слов описания элементов управления (Window, Button) и слов интерфейса логики представления (PerformAction1Command). Реализация всего этого языка для дизайнера не важна, её можно подменять для целей тестирования на любую. Важен сам язык и то, что на нём можно описать задачу. Здесь это работает.

Однако, язык, в котором есть слова Window и Button описывает задачу до тех пор, пока задача действительно про окна и кнопки. Если мы захотим говорить о выводе информации и вводе информации, то да, "Main Window" – это вывод информации, а кнопка - это ввод информации, но для нас это будут понятия более низкого уровня, и XAML станет языком решения, а не задачи.

А как быть с примером про обработку данных?

Чуть было не забыл. Чистый ООП. Мы используем DSL из библиотеки Yaapii.Atoms и пишем следующий код.

new LengthOf(
    new TeeInput(
        new InputOf(new Uri(sourcePath)),
        new OutputTo(new Uri(outputPath)))
    ).Value();

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

Но про это ли задача, которую мы решаем приведённым выше кодом? Мы действительно хотим обеспечить определённую вложенность объектов-входов и объектов выходов?

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

Значит, если мы решаем задачу про входы, выходы и их вложенность, то язык Yaapii.Atoms.IO для нас – язык задачи, а если про копирование файлов – то язык решения.

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

using System;
using Yaapii.Atoms;
using Yaapii.Atoms.IO;

namespace DslTestingGround.Yaapii
{
    public static class Data
    {
        public static Action<IOutput> FromFile(this Action<IInput, IOutput> transferData, string path) =>
            output => transferData(new InputOf(new Uri(path)), output);

        public static Action<IInput, IOutput> Copy() =>
            (input, output) => new LengthOf(new TeeInput(input, output)).Value();

        public static (bool success, Exception exception) ToFile(this Action<IOutput> useOutputStream, string path)
        {
            try
            {
                useOutputStream(new OutputTo(new Uri(path)));
                return (true, null);
            }
            catch (Exception ex)
            {
                return (false, ex);
            }
        }
    }
}

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

var (success, exception) = Data.Copy()
    .FromFile(sourcePath)
    .ToFile(outputPath);

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

Нас вообще устроят любые реализации C и D, лишь бы A был синглетоном Data, а D был ValueTuple<bool, Exception>, и сохранялись стрелочки с картинки.

Категория обработки данных

Например, вот реализация такого языка с использованием абстракций более низкого, чем у Yaapii.Atoms.IO, уровня потоков данных.

using System;
using System.IO;

namespace DslTestingGround.Streams
{
    public static partial class Data
    {
        public static Action<Stream> FromFile(this Action<Stream, Stream> transferData, string path)
        {
            return outputStream =>
            {
                using (var inputStream = File.Open(path, FileMode.Open))
                    transferData(inputStream, outputStream);
            };
        }

        public static Action<Stream, Stream> Copy()
        {
            return (inputStream, outputStream) => inputStream.CopyTo(outputStream);
        }

        public static (bool success, Exception exception) ToFile(this Action<Stream> useOutputStream, string path)
        {
            try
            {
                using (var outputStream = File.Open(path, FileMode.Create))
                    useOutputStream(outputStream);
                return (true, null);
            }
            catch (Exception ex)
            {
                return (false, ex);
            }
        }
    }
}

Реализация языка предсказуемо стала чуть сложнее. Зато, в случае необходимости мы сможем отказаться от использования Yaapii.Atoms.IO, как от DSL промежуточного для наших задач уровня абстракции без ущерба для кода, описывающего сами задачи, что понизит риски задержек в случае если такие изменения понадобятся.

Какой урок можно из этого извлечь?

Говоря о предметно-ориентированных языках, очень важно понимать, на каких уровнях абстракции задачи они работают.

Является ли предлагаемый язык языком задачи или языком решения на данном уровне абстракции?

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

Если же мы используем язык решения, мы должны быть готовы к внезапным ненужным сложностям, которые могут возникать исключительно из-за того, как мы описываем на нашем языке задачи более высокого для него уровня абстракции.

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

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