Как написать монаду IO на C# (не) без помощи параллельной вселенной и машины времени

в 10:41, , рубрики: C#, haskell, Вселенная, машина времени, монада IO, ненормальное программирование, функциональное программирование

В жизни нередко бывают ситуации когда нужно просто сесть и сделать какое-то дело, не забивая себе голову вопросами вроде "а что это даст?", "а кому это нужно?" и т. п. Написание монады IO — безусловно именно такой случай. Поэтому под катом находится рассказ о том как написать монаду IO на C#, без малейших попыток объяснить зачем это делать.

Баянистая картинка, отражающая суть статьи
Как написать монаду IO на C# (не) без помощи параллельной вселенной и машины времени - 1

Интерпретации монады IO

Есть два способа осмысления (интепретации) монады IO.

С первой интерпретацией можно ознакомиться, например, в замечательной статье IO inside. Хотя это и не декларировано явно, в этой статье IO рассматривается как способ обмануть компилятор Haskell, впихнуть вызовы «грязных» нативных функций в чистый код благодаря возможности формально обращаться с ними как с чистыми из-за добавления в сигнатуру фиктивных элементов.

Вторую же интерпретацию можно понять, заметив, что для других монад (например, Get из модуля Data.Binary) имеются функции типа m a -> a (для Get это функция runGet), то есть существует возможность вытащить значение из монады. Для IO же такой функции нет, и единственный способ её выполнить — вернуть из функции main нативному рантайму. То есть IO — это список действий (сценарий), и задача чистого кода этот список действий сформировать, но не выполнять.

Ни одна из этих интерпретаций не помогает транслировать понятие IO на C#: в первом случае мы замечаем, что в C# и так нет никакой трудности вызвать «грязный» код из любого места, а во втором что в C# все функции и программа целиком — это и есть список действий, а точка с запятой есть ни что иное как монадный оператор >>=.

Очевидно, что проблема в уникальности IO: в то время как другие монады являются синтаксическим сахаром, ввод-вывод есть такая операция, которую затруднительно осуществить, оставаясь в рамках чистого кода. И если найти способ написать реально чистый (без монад, без прямого или косвенного вызова нативных функций) код, осуществляющий ввод-вывод, то IO предстанет просто синтаксическим сахаром для этих чистых функций, объектом того же рода, что и монада Maybe. Ну а уж Maybe на C# не писал только ленивый.

Чистые функции ввода-вывода на Haskell. Принцип действия

Пусть у нас (в рамках некоторого языка программирования) есть функция beep, которая возвращет число 7 и выводит на экран сообщение «Beep!» и другая функция returnBeep, которая просто возвращает число 7. Что можно сказать о чистоте этих функций?

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

Однако, при использовании чистой функции returnBeep в программе её надо вычислить, и побочные эффекты при вычислении также будут присутствовать, как минимум в виде рассеиваемого компьютером тепла. Но значит ли это, что функция returnBeep перестала быть чистой из-за этого? Ответить на этот вопрос можно по-разному и — так же как и в случае вопроса «пересекаются ли параллельные прямые?» — любой из ответов можно принять за аксиому и построить на этом непротиворечивую теорию, позволяющую создать модель некоторой части окружающего мира. Так что мы примем как аксиому, что особенности реализации вычислителя и, в частности, создаваемые им при вычислении функции побочные эффекты не влияют на чистоту вычисляемой функции.

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

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

Что остаётся сделать в коде программы — это убедиться что нужные буквы на экран вывелись и узнать какие ввелись. И вот для этого нам и пригодятся машина времени и параллельная вселенная.

Идея состоит в том, чтобы чтобы засериализить Вселенную (всё, что было, есть и будет), затем закодировать (для удобства) в base64 и включить в нашу программу в виде захардкоженной строковой константы. На самом деле, я не уверен как в необходимости указанного набора (машина времени + параллельная вселенная), так и в его достаточности. Однако, без параллельной вселенной, по-моему, обойтись не получится, так как засериализить вселенную, находясь при этом в самой этой вселенной будет, пожалуй, посложнее чем написать quin на POSIX shell.

Конечно, для того чтобы программа на Haskell (являющаяся чистой функцией) могла вытащить из константы Вселенной информацию, относящуюся к конкретному запуску (инстансу) этой программы, на вход она должна принимать некий идентификатор этого инстанса. И она действительно его принимает — в виде значения типа RealWorld (см. уже упоминавшуюся статью IO inside). Прямая работа со значениями этого типа в Haskell невозможна, но нетрудно превратить значение типа RealWorld в значение типа Integer, String или любого другого, используя доступные стандартные функции. Конкретный тип и способ преобразования зависит от кодировки константы Вселенной и реализации функций ввода-вывода.

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

getOutText :: AppInstance -> IOIndex -> Maybe String
getInText :: AppInstance -> IOIndex -> Maybe String

Функция getOutText принимает инстанс приложения и номер текста в диалоге юзера и программы и возвращает соответствующий выведенный компьютером текст либо Nothing если входные параметры некорректны. Результат Nothing возвращается, например, если переданный номер соответствует введённому юзером, а не выведенному компьютером тексту. Так, если указанный инстанс программы ничего не выводил, то для любого значения номера должно возвращаться Nothing. Функция getInText принимает такие же аргументы и возвращает соответствующий введённый юзером текст либо Nothing.

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

isOutTextEquals :: String -> AppInstance -> IOIndex -> Bool
isOutTextEquals text inst index = getOutText inst index == Just text

Чистые функции ввода-вывода на Haskell. Модельная реализация

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

Итак, пусть существует некоторая вселенная, в которой живёт некоторое существо по имени noname. Noname учится в вузе на специальности computer science, и ему для сдачи госэкзамена по программированию требуется написать проект — консольную программу, которая спрашивает у пользователя имя, считывает ответ и выводит приветствие:

What is your name?
Вася
Hi, Вася!

Единственный язык программирования в этой вселенной — Haskell, причём без встроенной поддержки монады IO. Заготовка программы, созданная noname, выглядит так:

Код
module Main_ where
import Control.Monad
import Data.Vector (Vector, (!?))
import qualified Data.ByteString.Lazy.UTF8 as U
import Data.ByteString.Base64.Lazy

worldBase64 :: String
worldBase64
  =  "V29ybGQge2FwcEluc3RhbmNlcyA9IFtbSU9PcGVyYXRpb24gSU9Xcml0ZSAiV2hhdCBpcyB5b3Vy"
  ++ "IG5hbWU/CiIsSU9PcGVyYXRpb24gSU9SZWFkICJub25hbWUiLElPT3BlcmF0aW9uIElPV3JpdGUg"
  ++ "IkhpLCBub25hbWUhCiJdLFtJT09wZXJhdGlvbiBJT1dyaXRlICJXaGF0IGlzIHlvdXIgbmFtZT8K"
  ++ "IixJT09wZXJhdGlvbiBJT1JlYWQgIlwxMDQyXDEwNzJcMTA4OVwxMTAzIixJT09wZXJhdGlvbiBJ"
  ++ "T1dyaXRlICJIaSwgXDEwNDJcMTA3MlwxMDg5XDExMDMhCiJdLFtJT09wZXJhdGlvbiBJT1dyaXRl"
  ++ "ICJXaGF0IGlzIHlvdXIgbmFtZT8KIixJT09wZXJhdGlvbiBJT1JlYWQgIlwxMDU0XDEwODNcMTEw"
  ++ "MyIsSU9PcGVyYXRpb24gSU9Xcml0ZSAiSGksIFwxMDU0XDEwODNcMTEwMyEKIl1dfQo="

type AppInstance = Int
type IOIndex = Int
data IOAction = IORead | IOWrite deriving (Eq, Show, Read)
data IOOperation = IOOperation IOAction String deriving (Show, Read)
data World = World { appInstances :: Vector (Vector IOOperation) } deriving (Show, Read)

world :: World
world = read $ U.toString $ decodeLenient $ U.fromString worldBase64

getInOutText :: IOAction -> AppInstance -> IOIndex -> Maybe String
getInOutText action app i = do
  IOOperation actual_action result <- (!? i) <=< (!? app) $ appInstances world
  if actual_action == action then return result else Nothing

getInText :: AppInstance -> IOIndex -> Maybe String
getInText = getInOutText IORead

getOutText :: AppInstance -> IOIndex -> Maybe String
getOutText = getInOutText IOWrite

isOutTextEquals :: String -> AppInstance -> IOIndex -> Bool
isOutTextEquals text inst index = getOutText inst index == Just text

_main :: AppInstance -> Maybe String
_main app = do
  let question = "What is your name?n"
  _ <- if isOutTextEquals question app 0 then return () else Nothing
  name <- getInText app 1
  let greeting = "Hi, " ++ name ++ "!n"
  _ <- if isOutTextEquals greeting app 2 then return () else Nothing
  return $ question ++ name ++ "n" ++ greeting

По историческим причинам главные функция и модуль в этой вселенной называются, соответственно, Main_ и _main, и, как можно видеть, функция _main имеет тип AppInstance -> Maybe String. В реализации noname _main возвращает протокол диалога — это не требуется условиями задачи, но может быть полезно в целях отладки.

Noname проверил рабоспособность программы, запустив её и введя своё имя — и программа вроде бы сработала как надо — запросила имя и в ответ на «noname» выдала «Hi, noname!»

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

Однако, несмотря на то, что программа сработала верно при тестовом первом запуске, у noname нет уверенности, что программа и дальше будет работать: ведь «константу вселенной» (worldBase64) он вписал тестовую, поскольку настоящую он не знает. Поэтому noname разработал машину времени (оригинальной конструкции, с встроенным довольно мощным подавителем парадоксов) и вышел на связь с нашей Вселенной, передав листинг программы и чертежи машины времени в обмен на обещание предоставить ему точную константу его вселенной (точнее, интересующей его части) — нам, мол, отсюда виднее, чем ему изнутри.

Запустить эту иновселенскую программу как она есть, понятно, не получится. Но если снабдить её следующим стартовым модулем, то как-то она работать будет:

Код
module Main where

import System.Environment
import Data.Vector ((!?))
import qualified Data.Vector as V hiding ((!?))
import Main_

main :: IO ()
main = do
  args <- V.fromList <$> getArgs
  case _main =<< read <$> (args !? 0) of
    Just text -> putStr text
    Nothing -> putStrLn "Error!"

(Полный проект лежит на гитхабе: pure-io. Кстати если вы всё ещё не знаете, как управляться с инструментом сборки проекта и управления зависимостями stack, который пришёл на смену cabal, то вот хорошая статья: Прощай, cabal. Здравствуй, stack!.)

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

Поняв принцип программы и построив машину времени по предоставленным чертежам, несложно убедиться, что подправлять константу wordBase64 не требуется: программа будет запущена всего три раза (считая первый тестовый запуск), и все три раза её будет запускать автор, вводя те самые имена, которые он с самого начала закодировал в тексте программы!

Разобравшись таким образом с принципом действия чистого немонадного ввода-вывода и предоставив братскую помощь иновселенцу noname, перейдём от модельной вселенной к реальной и от Haskell к C#.

Чистые функции ввода-вывода на C#. Интерфейс

Из-за наличия механизма эксепшенов C#-функция, возвращающая X, по факту возвращает Either Exception X; в частности, void-функции «возвращают» Either Exception (). (К слову, в Haskell ситуация схожа, и условное определение тамошнего стандартного IO с учётом наличия эксепшенов выглядит не как type IO a = RealWorld -> (RealWorld, a), а скорее как type IO a = RealWorld -> (RealWorld, Either SomeException a).).

Учитывая означенную ситуацию с эксепшенами, выберем такие заготовки сигнатур наших чистых функций ввода-вывода:

static void AssertOutTextEquals(string text, AppInstance inst, int index);
static string GetInText(AppInstance inst, int index);

Функция AssertOutTextEquals — это та же isOutTextEquals, только вместо True она возвращает void без экспшена, а вместо False кидает эксепшен. Аналогично, функция GetInText либо возвращает ненулевую строку либо кидает эксепшен.

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

Посмотрим, нельзя ли как-то ещё отрефакторить и улучшить внешний синтаксис этих функций, не нарушая идеологии. Можно сделать их нестатическими членами AppInstance, а также использовать вместо void что-то более идиоматическое (для функционального кода):

public sealed class None {
    public static None _ { get { return null; } }
    None() { }
}
public sealed class AppInstance {
    public None AssertOutTextEquals(string text, int index);
    publi string GetInText(int index);
}

Использование None вместо void позволит избежать ненужного дублирования кода и облегчит написание собственно монады.

Далее, можно заметить, что первая функция является функциональным эквивалентом Console.Write(string), а вторая — Console.ReadLine(). Помимо этих в классе Console есть ещё много полезных функций ввода и вывода и, используя linq expressions, мы можем обобщить наши чистые функции так, чтобы поддержать сразу их все:

public None AssertOutTextEquals(Expression<Action> ioExpression, int index);
public TResult GetInText(Expression<Func<TResult>> ioExpression, int index);

Наконец, переставим для удобства параметры и дадим методам одинаковое краткое имя, чтобы подчеркнуть симметрию:

public None AssertIO(int index, Expression<Action> ioExpression);
public TResult AssertIO(int index, Expression<Func<TResult>> ioExpression);

Такая унификация оправдана тем, что если бы None было частью стандартной экосистемы, то вместо Action мы имели бы Func<None> и первая функция исчезла бы как частный случай второй.

Для того, чтобы мы могли писать юнит-тесты, необходимо предусмотреть возможность работы не только с реальной консолью, но и с тестовой заменой:

Код
public sealed class AppInstance {
    readonly static Lazy<AppInstance> inst = new Lazy<AppInstance>(() => new AppInstance((method, argTypes, args) => typeof(Console).GetMethod(method, BindingFlags.Static | BindingFlags.Public, null, argTypes, null).Invoke(null, args)));
    public static AppInstance Get() { return inst.Value; }

    readonly Func<string, Type[], object[], object> consoleDescriptor;

    internal AppInstance(Func<string, Type[], object[], object> consoleDescriptor) {
        this.consoleDescriptor = consoleDescriptor;
    }
}
public static class AppInstanceTestExtensions {
    public static AppInstance ForTests(this AppInstance inst, Func<string, Type[], object[], object> consoleDescriptor) {
        return new AppInstance(consoleDescriptor);
    }
}

Подготовим тестовое окружение:

Код
[TestFixture]
public class Tests {
    TestConsole console;
    AppInstance testInst;

    protected void Setup(string input) {
        console = new TestConsole(input);
        testInst = AppInstance.Get().ForTests((method, argTypes, args) => {
            var call = new object[] { console, console.In, console.Out }.Select(x => new { t = x, m = x.GetType().GetMethod(method, argTypes) }).Where(x => x.m != null).First();
            return call.m.Invoke(call.t, args);
        });
    }
}
public class TestConsole {
    readonly MemoryStream output;
    StreamWriter writer;
    readonly MemoryStream input;
    StreamReader reader;

    public TestConsole(string input) {
        this.input = new MemoryStream(Encoding.UTF8.GetBytes(input));
        this.reader = new StreamReader(this.input);
        this.output = new MemoryStream();
        this.writer = new StreamWriter(this.output);
    }

    public TextWriter Out { get { return writer; } }
    public TextReader In { get { return reader; } }
    public string Output {
        get {
            if(writer != null) {
                writer.Close();
                writer = null;
            }
            return Encoding.UTF8.GetString(output.ToArray());
        }
    }
}

Чистые функции ввода-вывода на C#. Тесты

Начнём с простого:

[Test]
public void WriteChars() {
    Setup("");
    testInst.AssertIO(0, () => Console.Write('A'));
    testInst.AssertIO(1, () => Console.Write('B'));
    Assert.AreEqual("AB", console.Output);
}

Заметим, что здесь тестируется не только работоспособность чистой функции AssertIO, но и сайд-эффекты: в коде написано ... Write('A') ... Write('B') ..., и ожидается что на экран будет выведено «AB».

Попробуем что-нибудь поинтереснее. Например, переставим местами вызовы AssertIO. Поскольку AssertIO — чистая функция, то пожет показаться, что результат (отсутствие эксепшенов) не должен измениться. Но это не так: это другой тест, в нём другой AppInstance, и поэтому результат может измениться (хотя может и не измениться). На практике оказывается, что в таком случае ничего не выводится:

[Test]
public void WriteCharsInBackOrder() {
    Setup("");
    Assert.Throws<ArgumentOutOfRangeException>(() => testInst.AssertIO(1, () => Console.Write('B')));
    Assert.Throws<ArgumentOutOfRangeException>(() => testInst.AssertIO(0, () => Console.Write('A')));
    Assert.AreEqual("", console.Output);
}

Чистая функция должна возвращать один и тот же результат на одном и том же наборе аргументов:

[Test]
public void WriteCharTwice() {
    Setup("");
    testInst.AssertIO(0, () => Console.Write('A'));
    testInst.AssertIO(0, () => Console.Write('A'));
    Assert.Throws<ArgumentException>(() => testInst.AssertIO(0, () => Console.Write('B')));
    Assert.AreEqual("A", console.Output);
}

Если вывод не удался по какой-то причине, мы должны получить ошибку в виде эксепшена:

[Test]
public void GetWriteError() {
    Setup("");
    console.Out.Close();
    Assert.Throws<AggregateException>(() => testInst.AssertIO(0, () => Console.Write('A')));
    Assert.Throws<ArgumentException>(() => testInst.AssertIO(0, () => Console.Write('B')));
}

Нам также нужен хотя бы один тест на чтение:

[Test]
public void ReadChar() {
    Setup("123");
    Assert.AreEqual((int)'1', testInst.AssertIO(0, () => Console.Read()));
    Assert.AreEqual((int)'2', testInst.AssertIO(1, () => Console.Read()));
    Assert.AreEqual((int)'3', testInst.AssertIO(2, () => Console.Read()));
    Assert.AreEqual(-1, testInst.AssertIO(3, () => Console.Read()));
}

Чистые функции ввода-вывода на C#. Реализация

Напишем вспомогательный класс, который будет разбирать переданный linq expression и осуществлять реальный вызов указанного метода с указанными параметрами, используя переданный consoleDescriptor:

Код
class IOOperation<TResult> {
    readonly string method;
    readonly Type[] argTypes;
    readonly object[] args;

    public IOOperation(LambdaExpression callExpression) {
        var methodExpr = (MethodCallExpression)callExpression.Body;
        this.args = methodExpr.Arguments.Select(x => Expression.Lambda<Func<object>>(Expression.Convert(x, typeof(object))).Compile()()).ToArray();
        this.method = methodExpr.Method.Name;
        this.argTypes = methodExpr.Method.GetParameters().Select(x => x.ParameterType).ToArray();
    }

    public TResult Do(Func<string, Type[], object[], object> consoleDescriptor) {
        return (TResult)consoleDescriptor(method, argTypes, args);
    }
}

Для отслеживания одинаковых вызовов (как в тесте WriteCharTwice) удобно перекрыть Equals:

Код
public static bool operator ==(IOOperation<TResult> a, IOOperation<TResult> b) {
    bool aIsNull = ReferenceEquals(a, null);
    bool bIsNull = ReferenceEquals(b, null);
    return
        aIsNull && bIsNull ||
        !aIsNull && !bIsNull &&
        string.Equals(a.method, b.method, StringComparison.Ordinal) &&
        a.args.Length == b.args.Length &&
        !a.args.Zip(b.args, Equals).Where(x => !x).Any();
}
public override int GetHashCode() { return method.GetHashCode() ^ args.Length; }
public static bool operator !=(IOOperation<TResult> a, IOOperation<TResult> b) { return !(a == b); }
public override bool Equals(object obj) { return this == obj as IOOperation<TResult>; }

Теперь мы можем свести два метода AssertIO к одному:

public None AssertIO(int index, Expression<Action> ioExpression) {
    return AssertIO(index, new IOOperation<None>(ioExpression));
}
public TResult AssertIO<TResult>(int index, Expression<Func<TResult>> ioExpression) {
    return AssertIO(index, new IOOperation<TResult>(ioExpression));
}
TResult AssertIO<TResult>(int index, IOOperation<TResult> operation);

Осталось реализовать последний метод — и дело сделано.

Ясно, что нам надо как-то кэшировать рузультаты наших обращений к нативной консоли. Добавим необходимые классы и филды:

Код
readonly List<IOOperationWithResult> completedOperations = new List<IOOperationWithResult>();

abstract class IOOperationResult { }
sealed class IOOperationResult<TResult> : IOOperationResult {
    readonly TResult returnValue;
    readonly Exception exception;

    public IOOperationResult(Func<TResult> getResult) {
        try {
            returnValue = getResult();
            exception = null;
        } catch(Exception e) {
            returnValue = default(TResult);
            exception = e;
        }
    }

    public TResult Result {
        get {
            if(exception != null)
                throw new AggregateException(exception);
            return returnValue;
        }
    }
}
abstract class IOOperationWithResult { }
sealed class IOOperationWithResult<TResult> : IOOperationWithResult {
    public IOOperationWithResult(IOOperation<TResult> operation, IOOperationResult<TResult> result) {
        Operation = operation;
        Result = result;
    }
    public readonly IOOperation<TResult> Operation;
    public readonly IOOperationResult<TResult> Result;
}

Проделав предаврительную работу, можно наконец написать собственно AssertIO:

Код
bool rejectOperations = false;

TResult AssertIO<TResult>(int index, IOOperation<TResult> operation) {
    if(index < 0)
        throw new ArgumentOutOfRangeException("index");
    if(index < completedOperations.Count) {
        var completedOperation = completedOperations[index] as IOOperationWithResult<TResult>;
        if(completedOperation == null || completedOperation.Operation != operation)
            throw new ArgumentException("", "operation");
        return completedOperation.Result.Result;
    }
    if(rejectOperations)
        throw new ArgumentOutOfRangeException("index");
    if(index == completedOperations.Count) {
        var completedOperation = new IOOperationWithResult<TResult>(operation, new IOOperationResult<TResult>(() => operation.Do(consoleDescriptor)));
        completedOperations.Add(completedOperation);
        return completedOperation.Result.Result;
    }
    rejectOperations = true;
    throw new ArgumentOutOfRangeException("index");
}

Алгоритм работы простой. Если приходит запрос о результате уже выполненной операции, то мы лезем в кэш и сверяем IOOperation. Если мы имеем полное совпадение, значит действительно был произведён вызов указанного метода с указанными параметрами, и мы возвращаем результат; а если имеется отличие — рейзим экспешен. Далее, если операции в кэше ещё нет и сейчас как раз подходящее время чтобы её выполнить — выполняем операцию, добавляем вместе с полученным результатом в кэш, и возвращаем результат. Если же операции в кэше нет и для её выполнения требуется машина времени — ничего хорошего не выйдет, так что остаётся упасть, взведя предварительно специальный флажок rejectOperations, который обеспечит консистентность поведения метода при дальнейших вызовах.

Такая простая реализация ведёт себя так же, как возможная реализация на чистых функциях, и обеспечивает прохождение написанных (и ненаписанных) тестов.

Монада IO

Теперь, когда у нас есть чистые немонадные функции ввода-вывода, написать монаду IO не представляет никакого труда:

Код
public sealed class IO<T> {
    readonly Func<RealWorld, Tuple<RealWorld, T>> func;

    internal IO(Func<RealWorld, Tuple<RealWorld, T>> func) {
        this.func = func;
    }
    internal RealWorld Execute(RealWorld index, out T result) {
        var resultTuple = func(index);
        result = resultTuple.Item2;
        return resultTuple.Item1;
    }
}
class RealWorld {
    readonly AppInstance inst;
    readonly int index;

    public RealWorld(AppInstance inst, int index) {
        this.inst = inst;
        this.index = index;
    }
    public Tuple<RealWorld, None> Do(Expression<Action> callExpression) {
        return Tuple.Create(Yield(), inst.AssertIO(index, callExpression));
    }
    public Tuple<RealWorld, TResult> Do<TResult>(Expression<Func<TResult>> callExpression) {
        return Tuple.Create(Yield(), inst.AssertIO(index, callExpression));
    }
    public RealWorld Yield() {
        return new RealWorld(inst, index + 1);
    }
}

Мы даже можем поддержать имеющийся в C# специальный монадный синтаксис (from ... in ... select ...). Для этого кроме наших кастомных методов Return и Do понадобится реализовать методы Select и SelectMany (они должны называться именно так и иметь определённую сигнатуру — работает утиная типизация):

Код
public static class IO {
    public static IO<T> Return<T>(T value) {
        return new IO<T>(x => Tuple.Create(x, value));
    }
    public static IO<R> Select<T, R>(this IO<T> io, Func<T, R> selector) {
        return new IO<R>(x => {
            T t;
            var index = io.Execute(x, out t);
            return Tuple.Create(index, selector(t));
        });
    }
    public static IO<R> SelectMany<T, C, R>(this IO<T> io, Func<T, IO<C>> selector, Func<T, C, R> projector) {
        return new IO<R>(x => {
            T t;
            var index = io.Execute(x, out t);
            var ioc = selector(t);
            C c;
            var resultIndex = ioc.Execute(index, out c);
            return Tuple.Create(resultIndex, projector(t, c));
        });
    }
    public static IO<None> Do(Expression<Action> callExpression) {
        return new IO<None>(x => x.Do(callExpression));
    }
    public static IO<TResult> Do<TResult>(Expression<Func<TResult>> callExpression) {
        return new IO<TResult>(x => x.Do(callExpression));
    }
    public static IO<T> Handle<T>(this IO<T> io, Func<Exception, IO<T>> handler) {
        return new IO<T>(x => {
            RealWorld rw;
            T t;
            try {
                rw = io.Execute(x, out t);
            } catch(Exception e) {
                rw = handler(e).Execute(x.Yield(), out t);
            }
            return Tuple.Create(rw, t);
        });
    }
}

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

Также понадобится «монадная» точка входа в приложение:

public static class AppInstanceIOExtensions {
    public static void DoMain(this AppInstance inst, Func<IO<None>> body) {
        None result;
        body().Execute(new RealWorld(inst, 0), out result);
    }
}

Демонстрация результата

Напишем простейшее консольное приложение, используя монаду IO:

class Program {
    static void Main(string[] args) {
        AppInstance.Get().DoMain(IOMain);
    }
    static IO<None> IOMain() {
        return
            from _ in IO.Do(() => Console.WriteLine("What is your name?"))
            from name in IO.Do(() => Console.ReadLine())
            let message = "Hi, " + name + "!"
            from r in IO.Do(() => Console.WriteLine(message))
            select r;
    }
}

Результат работы:

Как написать монаду IO на C# (не) без помощи параллельной вселенной и машины времени - 2

Полный код на гитхабе: IOMonad.

Автор: warlock13

Источник

Поделиться новостью

* - обязательные к заполнению поля