📜 ⬆️ ⬇️

How to write an IO monad in C # (not) without the help of a parallel universe and time machine

In life, there are often situations when you just need to sit down and do some business, not bothering with questions like “what will it give?”, “And who needs it?” and so on. Writing a monad of IO — definitely the case. Therefore, under the cut there is a story about how to write an IO monad in C #, without the slightest attempt to explain why .

Bayan picture reflecting the essence of the article


Interpretations of the monad IO


There are two ways of understanding (interpreting) the monad of IO.

The first interpretation can be found, for example, in the remarkable IO inside article. Although it is not explicitly declared, in this article IO considered as a way to deceive the Haskell compiler , to shove the calls of “dirty” native functions into clean code due to the ability to treat them formally as clean because of the addition of fictitious elements to the signature.
')
The second interpretation can be understood by noting that for other monads (for example, Get from the Data.Binary module) there are functions of type ma -> a (for Get this is the runGet function), that is, it is possible to pull the value out of the monad. For IO , there is no such function, and the only way to do it is to return the native runtime from the main function. That is, IO is a list of actions (script), and the task of clean code is to create this list of actions, but not to perform them.

None of these interpretations helps translate the notion of IO to C # : in the first case we notice that in C # there is no difficulty in calling the “dirty” code from any place, and in the second that in C # all functions and the whole program are there is a list of actions, and the semicolon is nothing more than the monadic operator >>= .

Obviously, the problem is in the uniqueness of IO : while the other monads are syntactic sugar, input-output is an operation that is difficult to accomplish while staying within the framework of a clean code. And if you find a way to write a really clean (without monads, without directly or indirectly calling native functions) I / O code, IO will simply appear as syntactic sugar for these pure functions, an object of the same kind as the Maybe monad. Well, Maybe in C # did not write just lazy.

Pure Haskell I / O functions. Operating principle


Suppose we (within some programming language) have a beep, function that returns the number 7 and displays the message “Beep!” And another function returnBeep , which simply returns the number 7. What can be said about the purity of these functions?

At first glance, it seems that returnBeep clean, but beep is not: a pure function is a function that does not produce side effects, and in the latter case there is clearly a side effect.

However, when using the pure returnBeep function in the program, it must be calculated, and side effects will also be present during the calculation, at least in the form of heat dissipated by computer. But does this mean that the returnBeep function returnBeep ceased to be clean because of this? This question can be answered in different ways and - just as in the case of the question "Do parallel lines intersect?" - any of the answers can be taken as an axiom and build on this consistent theory to create a model for some part of the surrounding world. So we will accept as an axiom that the features of the implementation of the calculator and, in particular, the side effects created by it when calculating the function do not affect the purity of the calculated function .

But from this it immediately follows that not only returnBeep, but also beep, is clean beep, since issuing a message does not affect the course of the program execution and therefore it is the same implementation feature of the calculator.

In accordance with the foregoing, it is not impossible that, when executing a program consisting only of pure functions, what was to be deduced and what was to be read out — that is, the computer behaved as if our computer programs have a side effect, although there is none. Moreover, the provision of such activity is shifted from our shoulders - the shoulders of the application programmer - onto the shoulders of the developers of the artist; that is, on the cumulative virtual shoulders of microprocessor developers, compilers, Haskell runtime, and so on.

What remains to be done in the program code is to make sure that the necessary letters are displayed on the screen and find out which ones were entered. And for this we will need a time machine and a parallel universe.

The idea is to serialize the Universe (everything that was, is and will be), then encode (for convenience) on base64 and include in our program as a hard-coded string constant. In fact, I’m not sure of the need for this set (time machine + parallel universe) or of its sufficiency. However, in my opinion, it will not be possible to do without a parallel universe, since it will probably be more difficult than to write quin on the POSIX shell while it is in the universe itself.

Of course, in order for a Haskell program (being a pure function) to pull information from a Universe constant related to a particular launch (instanta) of this program, it must take some kind of identifier for this instance as input. And it really takes it - in the form of a value like RealWorld (see the already mentioned article IO inside ). Direct work with values ​​of this type in Haskell is impossible, but it is easy to turn a value of type RealWorld into a value of type Integer, String or any other, using the available standard functions. The specific type and method of conversion depends on the encoding of the constant of the Universe and the implementation of I / O functions.

Acting in this way, it will not be difficult to create a library of pure I / O functions. Function signatures can be, for example, such (for simplicity, we restrict ourselves to the simplest console I / O lines):

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

The getOutText function accepts the application instance and the text number in the user and program dialog and returns the corresponding text output by the computer or Nothing if the input parameters are incorrect. The result Nothing returned, for example, if the transferred number corresponds to the text entered by the user, and not to the text displayed by the computer. So, if the specified instance of the program did not display anything, then for any value of the number Nothing. should be returned Nothing. The getInText function takes the same arguments and returns the corresponding text entered by the user or Nothing .

In fact, it turns out that instead of the getOutText function, getOutText more convenient to use the getOutText function, which is more limited but practical isOutTextEquals with the following signature and semantics:

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

Pure Haskell I / O functions . Model implementation


Despite the fact that it is difficult to bring the ideas described above to the working code for our Universe, we can do this for a simple model universe.

So, let there be some universe in which a certain creature named noname lives. Noname is studying computer science at a university, and in order to pass the state programming exam, he needs to write a project — a console program that asks the user for a name, reads the answer, and displays a greeting:
What is your name?

Hi, !

The only programming language in this universe is Haskell , and without the built-in support of the IO monad. The program blank, created by noname, looks like this:

Code
 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 


For historical reasons, the main function and module in this universe are called, respectively, Main_ and _main , and, as you can see, the _main function has the type AppInstance -> Maybe String . In the noname implementation, _main returns the dialog protocol — this is not required by the conditions of the problem, but may be useful for debugging purposes.

Noname checked the working capacity of the program, running it and entering its name - and the program seemed to work as it should - requested a name and in response to “noname” issued “Hi, noname!”

(As you can see in the program, there are no explicit indications of what exactly should be displayed on the screen and at what points to wait for user input, so the fact that this program is working is very indicative of the demonstration of the power of the heuristics inherent in Haskell’s standard noname universe).

However, despite the fact that the program worked correctly during the first test run, noname is not sure that the program will continue to work: after all, he entered the test “universe constant” ( worldBase64 ) because it doesn’t know the real one. Therefore, noname developed a time machine (of the original design, with a rather powerful paradoxes built-in) and contacted our Universe by transmitting the program listing and time machine drawings in exchange for the promise to provide it with the exact constant of its universe (more precisely, its parts). supposedly from here it is more visible, than to it from within.

Run this Inovselenskaya program as it is, of course, will not work. But if you equip it with the following starting module, then somehow it will work:

Code
 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!" 


(The complete project is on the githaba: pure-io . By the way, if you still don’t know how to manage the stack build and dependency management tool that replaced cabal , here’s a good article: Goodbye, cabal. Hello, stack!. )

Of course, we will not see the “displayed” messages, nor will we be able to enter a name, because we do not have a clever Inovselensky district time — only the record of the corresponding dialog returned by the _main function will be available to us. You will also have to manually transmit the launch number ( AppInstance ) as a command line argument, but this is enough to understand and model the functioning of this program in its real environment.

Understanding the principle of the program and building a time machine according to the provided drawings, it is easy to make sure that wordBase64 do not need to correct the wordBase64 constant: the program will be run only three times (assuming the first test run), and the author will run it all three times, introducing the very names from the very beginning encoded in the text of the program!

Having dealt with the principle of pure non-monadic I / O in this way and providing fraternal help to the noname alien, we move from the model universe to the real one and from Haskell to C # .

Pure I / O functions in C #. Interface


Due to the presence of an executing mechanism, C # is a function that returns X, in fact, returns Either Exception X ; in particular, the void functions "return" Either Exception (). (By the way, in Haskell the situation is similar, and the conditional definition of the standard IO , taking into account the presence of exceptions, does not look like type IO a = RealWorld -> (RealWorld, a), but rather like type IO a = RealWorld -> (RealWorld, Either SomeException a). ).

Considering the above situation with exceptions, we’ll select these signature templates for our pure I / O functions:

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

The AssertOutTextEquals function is the same isOutTextEquals, only instead of True it returns void without an expression, and instead of False throws an exception. Similarly, the GetInText function either returns a non-zero string or throws an exception.

These functions are pure, so we could express them through other pure functions (as was shown in the previous section), but instead we will go the other way. Namely, we will create an equivalent native implementation - thanks to this, we will not need parallel universes and time machines.

Let's see if it is possible to somehow refactor and improve the external syntax of these functions without violating the ideology. You can make them non-static members of AppInstance , and also use something more idiomatic instead of void (for functional code):

 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); } 

Using None instead of void will avoid unnecessary code duplication and make it easier to write the monad itself.

Further, you will notice that the first function is the functional equivalent of Console.Write(string), and the second is Console.ReadLine(). In addition to these, there are many more useful input and output functions in the Console class and, using linq expressions, we can summarize our pure functions so as to support them all at once:

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

Finally, let's rearrange the parameters for convenience and give the methods the same short name to emphasize symmetry:

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

This unification is justified by the fact that if None were part of a standard ecosystem, instead of Action we would have Func<None> and the first function would disappear as a special case of the second.

In order for us to write unit tests, it is necessary to provide for the possibility of working not only with a real console, but also with a test replacement:

Code
 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); } } 


Prepare the test environment:

Code
 [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 => xm != 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()); } } } 


Pure I / O functions in C #. Tests


Let's start with the simple:

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

Note that not only the performance of the pure AssertIO, function is AssertIO, but also the side effects: the code says ... Write('A') ... Write('B') ..., and it is expected that the screen will be displayed "AB".

Let's try something more interesting. For example, swap AssertIO. calls AssertIO. Since AssertIO is a pure function, it would seem that the result (the absence of exceptions) should not change. But this is not the case: this is a different test, there is another AppInstance in it, and therefore the result may change (although it may not change). In practice, it turns out that in this case nothing is output:

 [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); } 

A pure function must return the same result on the same set of arguments:

 [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); } 

If the output failed for some reason, we should get an error in the form of an exception:

 [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'))); } 

We also need at least one reading test:

 [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())); } 

Pure I / O functions in C #. Implementation


Let's write an auxiliary class that will parse the passed linq expression and make a real call to the specified method with the specified parameters, using the passed consoleDescriptor :

Code
 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); } } 


To track identical calls (as in the WriteCharTwice test), it is convenient to block Equals:

Code
 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>; } 


Now we can reduce two AssertIO methods to one:

 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); 

It remains to implement the last method - and it's done.

It is clear that we need to somehow cache the results of our calls to the native console. Add the necessary classes and fields:

Code
 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; } 


Having done the preliminary work, you can finally write the actual AssertIO:

Code
 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"); } 


The algorithm is simple. If a request comes in on the result of an operation that has already been performed, then we crawl and check IOOperation. If we have a complete match, it means that the specified method was actually called with the specified parameters, and we return the result; and if there is a difference - raising is quick. Further, if there is no operation in the cache and now is just the right time to execute it, we perform the operation, add the result to the cache along with the result, and return the result. If there is no operation in the cache and a time machine is required for its execution, nothing good will come out, so it remains to fall, having previously raised a special flag rejectOperations,that will ensure the consistency of the method's behavior during further calls.

Such a simple implementation behaves in the same way as a possible implementation on pure functions, and ensures that written (and unwritten) tests pass.

Monad IO


Now that we have pure non-monadic I / O functions, writing a monad IOis easy:

Code
 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); } } 


We can even support the special monadic syntax available in C #(from ... in ... select ...). To do this, besides our custom methods Return, you Dowill need to implement the methods Selectand SelectMany(they should be called that way and have a certain signature - duck typing works):

Code
 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); }); } } 


In addition to the above, we have added a very useful method Handlethat allows you to continue working when an event occurs.

You will also need a “monadic” entry point to the application:

 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); } } 

Demonstration of the result


Let's write the simplest console application using the monad 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; } } 

The result of the work: The



full code on the githaba: IOMonad .

Source: https://habr.com/ru/post/282940/


All Articles