Skip to content

Classic OOP Composition Declarative

Vitaly Kamiansky edited this page Oct 30, 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 промежуточного для наших задач уровня абстракции без ущерба для кода, описывающего задачи, что снижает риски задержек в случае таких изменений.

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

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

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

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

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

Clone this wiki locally