Имеет ли монада IO смысл на языке, таком как С#
Проведя много времени на чтение и размышления, я думаю, что наконец понял, что такое монады, как они работают и для чего они полезны. Моя основная цель состояла в том, чтобы выяснить, были ли монады такими, какие я мог бы применить к своей повседневной работе на С#.
Когда я начал изучать монады, у меня создалось впечатление, что они волшебны и что они каким-то образом делают IO и другие нечистые функции чистыми.
Я понимаю важность монад для таких вещей, как LINQ в .Net и, возможно, очень полезно для работы с функциями, которые не возвращают действительные значения. И я также признателен за необходимость ограничить состояние в коде и изолировать внешние зависимости, и я надеялся, что монады тоже помогут с ними.
Но я, наконец, пришел к выводу, что монады для ввода-вывода и состояния управления являются необходимостью для Haskell, потому что у Haskell нет другого способа сделать это (в противном случае вы не смогли бы гарантировать последовательность, и некоторые вызовы были бы оптимизированы). Но для более распространенных языков монады не подходят для этих потребностей, так как большинство языков уже обрабатывают и записывают информацию и легко записываются.
Итак, мой вопрос: справедливо ли говорить, что монада IO действительно полезна только в Haskell? Есть ли веская причина для реализации монады IO, например, в С#?
Ответы
Ответ 1
Я использую Haskell и F # регулярно, и мне никогда не нравилось использовать IO или монаду штата в F #.
Основная причина для меня заключается в том, что в Haskell вы можете сказать по типу чего-то, что он не использует IO или состояние, и что это действительно ценная информация.
В F # (и С#) нет такого общего ожидания для кода других людей, и вам не пригодится большая добавка этой дисциплины к вашему собственному коду, и вы заплатите некоторые общие накладные расходы (в основном синтаксические) за прилипание к нему.
Монады также не очень хорошо работают на платформе .NET из-за отсутствия более высоких типов: пока вы можете писать монадический код в F # с рабочим процессом синтаксис и на С# с болью немного больше, вы не можете легко написать код, который абстрагирует несколько разных монад.
Ответ 2
На работе мы используем monads для управления IO в нашем коде С# на наших наиболее важных бизнес-логиках. Два примера - это наш финансовый код и код, который находит решения для проблемы оптимизации для наших клиентов.
В нашем финансовом коде мы используем монаду для управления записью ввода-вывода и чтения из нашей базы данных. Он по существу состоит из небольшого набора операций и абстрактного синтаксического дерева для операций монады. Вы могли бы представить себе что-то вроде этого (не фактический код):
interface IFinancialOperationVisitor<T, out R> : IMonadicActionVisitor<T, R> {
R GetTransactions(GetTransactions op);
R PostTransaction(PostTransaction op);
}
interface IFinancialOperation<T> {
R Accept<R>(IFinancialOperationVisitor<T, R> visitor);
}
class GetTransactions : IFinancialOperation<IError<IEnumerable<Transaction>>> {
Account Account {get; set;};
public R Accept<R>(IFinancialOperationVisitor<R> visitor) {
return visitor.Accept(this);
}
}
class PostTransaction : IFinancialOperation<IError<Unit>> {
Transaction Transaction {get; set;};
public R Accept<R>(IFinancialOperationVisitor<R> visitor) {
return visitor.Accept(this);
}
}
который по существу является кодом Haskell
data FinancialOperation a where
GetTransactions :: Account -> FinancialOperation (Either Error [Transaction])
PostTransaction :: Transaction -> FinancialOperation (Either Error Unit)
вместе с абстрактным синтаксическим деревом для построения действий в монаде, по существу, свободной монадой:
interface IMonadicActionVisitor<in T, out R> {
R Return(T value);
R Bind<TIn>(IMonadicAction<TIn> input, Func<TIn, IMonadicAction<T>> projection);
R Fail(Errors errors);
}
// Objects to remember the arguments, and pass them to the visitor, just like above
// Hopefully I got the variance right on everything for doing this without higher order types, which is how we used to do this. We now use higher order types in c#, more on that below. Here, to avoid a higher-order type, the AST for monadic actions is included by inheritance in
В реальном коде их больше, поэтому мы можем помнить, что для эффективности было создано .Select()
вместо .SelectMany()
. Финансовая операция, включая промежуточные вычисления, по-прежнему имеет тип IFinancialOperation<T>
. Фактическая производительность операций выполняется интерпретатором, который обертывает все операции с базой данных в транзакции и рассматривает, как отменить эту транзакцию, если какой-либо компонент не увенчался успехом. Мы также используем интерпретатор для модульного тестирования кода.
В нашем коде оптимизации мы используем монаду для управления IO для получения внешних данных для оптимизации. Это позволяет нам писать код, который не знает, как составлены вычисления, что позволяет нам использовать точно такой же бизнес-код в нескольких настройках:
- синхронный ввод-вывод и вычисления для вычислений, выполняемых по требованию
- асинхронный ввод-вывод и вычисления для многих вычислений, выполненных параллельно
- mocked IO для модульных тестов
Поскольку код должен быть передан, какую монаду нужно использовать, нам нужно четкое определение монады. Вот один. IEncapsulated<TClass,T>
по существу означает TClass<T>
. Это позволяет компилятору С# отслеживать все три части типа монад одновременно, преодолевая необходимость использования при работе с самими монадами.
public interface IEncapsulated<TClass,out T>
{
TClass Class { get; }
}
public interface IFunctor<F> where F : IFunctor<F>
{
// Map
IEncapsulated<F, B> Select<A, B>(IEncapsulated<F, A> initial, Func<A, B> projection);
}
public interface IApplicativeFunctor<F> : IFunctor<F> where F : IApplicativeFunctor<F>
{
// Return / Pure
IEncapsulated<F, A> Return<A>(A value);
IEncapsulated<F, B> Apply<A, B>(IEncapsulated<F, Func<A, B>> projection, IEncapsulated<F, A> initial);
}
public interface IMonad<M> : IApplicativeFunctor<M> where M : IMonad<M>
{
// Bind
IEncapsulated<M, B> SelectMany<A, B>(IEncapsulated<M, A> initial, Func<A, IEncapsulated<M, B>> binding);
// Bind and project
IEncapsulated<M, C> SelectMany<A, B, C>(IEncapsulated<M, A> initial, Func<A, IEncapsulated<M, B>> binding, Func<A, B, C> projection);
}
public interface IMonadFail<M,TError> : IMonad<M> {
// Fail
IEncapsulated<M, A> Fail<A>(TError error);
}
Теперь мы могли бы представить себе другой класс монады для части IO, которую наши вычисления должны уметь видеть:
public interface IMonadGetSomething<M> : IMonadFail<Error> {
IEncapsulated<M, Something> GetSomething();
}
Затем мы можем написать код, который не знает о том, как скомпилированы вычисления
public class Computations {
public IEncapsulated<M, IEnumerable<Something>> GetSomethings<M>(IMonadGetSomething<M> monad, int number) {
var result = monad.Return(Enumerable.Empty<Something>());
// Our developers might still like writing imperative code
for (int i = 0; i < number; i++) {
result = from existing in r1
from something in monad.GetSomething()
select r1.Concat(new []{something});
}
return result.Select(x => x.ToList());
}
}
Это можно использовать повторно как в синхронной, так и в асинхронной реализации IMonadGetSomething<>
. Обратите внимание, что в этом коде GetSomething()
будет происходить один за другим, пока не появится ошибка даже в асинхронной настройке. (Нет, это не то, как мы создаем списки в реальной жизни)
Ответ 3
Вы спрашиваете: "Нужна ли нам монашка IO в С#?" но вы должны спросить: "Нужен ли нам способ надежно получить чистоту и неизменность в С#?".
Ключевым преимуществом будет контроль над побочными эффектами. Если вы делаете это, используя монады или какой-то другой механизм, это не имеет значения. Например, С# может позволить вам отмечать методы как pure
и классы как immutable
. Это будет отличным способом укрощения побочных эффектов.
В такой гипотетической версии С# вы попытались бы сделать 90% вычислений чистым и иметь неограниченные, нетерпеливые IO и побочные эффекты в оставшихся 10%. В таком мире я не вижу столь необходимой потребности в абсолютной чистоте и монаде IO.
Обратите внимание, что просто механически преобразуя побочный код в монадический стиль, вы ничего не получаете. Код вообще не улучшает качество. Вы улучшаете качество кода за счет чистоты на 90% и концентрируете IO в небольших, легко просматриваемых местах.
Ответ 4
Возможность узнать, имеет ли функция побочные эффекты, просто взглянув на ее подпись, очень полезна, когда вы пытаетесь понять, что делает функция. Чем меньше функция может делать, тем меньше вы должны понимать! (Полиморфизм - это еще одна вещь, которая помогает ограничить функции, которые могут выполнять ее аргументы.)
На многих языках, реализующих программную транзакционную память, в документации есть предупреждения, подобные следующим:
Ввод/вывод и другие действия с побочными эффектами следует избегать в транзакций, так как транзакции будут повторены.
Если это предупреждение станет запретом, применяемым системой типов, это сделает язык более безопасным.
Оптимизация может выполняться только с кодом, который не содержит побочных эффектов. Но отсутствие побочных эффектов может быть трудно определить, если вы "разрешите что-либо" в первую очередь.
Другим преимуществом монады IO является то, что, поскольку действия ИО "инертны", если они не лежат на пути функции main
, легко манипулировать ими как данными, помещать их в контейнеры, составлять их во время выполнения, и так далее.
Конечно, монадический подход к IO имеет свои недостатки. Но у этого есть преимущества, кроме "одного из немногих способов делать ввод-вывод на чистом ленивом языке гибким и принципиальным образом".
Ответ 5
Как всегда, монада IO является особой и трудноразрешимой. В сообществе Haskell хорошо известно, что, хотя IO полезен, он не разделяет многих преимуществ других монадов. Он использует, как вы заметили, сильно мотивированный положением своих привилегий вместо того, чтобы быть хорошим инструментом моделирования.
С этим я бы сказал, что это не так полезно на С# или, действительно, на любом языке, который не пытается полностью содержать побочные эффекты с аннотациями типа.
Но это только одна монада. Как вы уже упоминали, в LINQ появляется Failure, но более сложные монады полезны даже на языке побочных эффектов.
Например, даже с произвольными глобальными и локальными государственными средами государственная монада будет указывать как начало, так и конец режима действий, которые работают в каком-то привилегированном виде. Вы не получаете побочные эффекты, гарантирующие Haskell, но у вас все еще есть хорошая документация.
Чтобы идти дальше, я представляю что-то вроде монаршицы из Parser. Наличие этой монады, даже в С#, является отличным способом локализовать такие вещи, как отказ от детерминированного отказа, который выполняется при потреблении строки. Вы, очевидно, можете сделать это с определенными видами изменчивости, но Monads выражают, что конкретное выражение выполняет полезное действие в этом эффективном режиме без учета какого-либо глобального состояния, которое вы также можете вовлечь.
Итак, я бы сказал, да, они полезны на любом типизированном языке. Но IO как Haskell это делает? Может быть, не так много.
Ответ 6
На языке, подобном С#, где вы можете делать IO в любом месте, монада IO действительно не имеет практического применения. Единственное, что вы хотели бы использовать для этого - это контролировать побочные эффекты, и, поскольку вам ничего не мешает выполнять побочные эффекты вне монады, на самом деле не так много.
Что касается монады Maybe
, хотя она кажется потенциально полезной, она действительно работает только на языке с ленивой оценкой. В следующем выражении Haskell второй lookup
не оценивается, если первый возвращает Nothing
:
doSomething :: String -> Maybe Int
doSomething name = do
x <- lookup name mapA
y <- lookup name mapB
return (x+y)
Это позволяет выражению "короткое замыкание", когда встречается a Nothing
. Реализация на С# должна была бы выполнять оба поиска (я думаю, мне было бы интересно увидеть встречный пример.) Вероятнее всего, вы можете использовать инструкции if.
Другой проблемой является потеря абстракции. Хотя, конечно, возможно реализовать монады в С# (или вещи, которые выглядят немного как монады), вы не можете на самом деле обобщать, как вы можете, в Haskell, потому что С# не имеет более высоких видов. Например, функция, подобная mapM :: Monad m => Monad m => (a -> m b) -> [a] -> m [b]
(которая работает для любой монады), на самом деле не может быть представлена на С#. У вас могло бы быть что-то вроде этого:
public List<Maybe<a> mapM<a,b>(Func<a, Maybe<b>>);
который будет работать для конкретной монады (Maybe
в этом случае), но невозможно отвлечь Maybe
от этой функции. Вы должны были бы сделать что-то вроде этого:
public List<m<a> mapM<m,a,b>(Func<a, m<b>>);
что невозможно в С#.