📜 ⬆️ ⬇️

Library patterns: Why frameworks are evil

Hello, dear readers!

Today we want to offer you a translation of a technical article, whose author, Thomas Petrichek, considers various aspects of working with libraries in the F # language. Since we are currently exploring the potential of one book, in the creation of which this author participated, the article is positioned primarily as a sample text, using as an example you can appreciate the author’s narrative talent, the quality of his ideas, arguments and reasoning, as well as code examples. However, since the considerations outlined in the article are not limited to working with F #, we hope that the text will be informative and interesting for the widest audience.


This article is written based on one of my previous posts on the design of functional libraries, but it will be quite clear without such an introductory post, since it is devoted to another topic.
')
In the previous article I described some principles that came in handy when writing libraries in a functional style. I rely on my own experience of creating libraries in the F # language, but the ideas that will be presented here are quite universal and will be useful when working with any programming language. In a previous post, I wrote how multiple levels of abstraction allow you to create libraries that simplify the implementation of 80% of the scenarios, but are useful and more interesting in practical cases.

In this article I'm going to talk about two other aspects: how to develop composable libraries and how (and most importantly - why) avoid callbacks when developing libraries. As is clear from the title of the article, its essence boils down to the following: write libraries, not frameworks!
Comparing frameworks and libraries

What is the difference between a framework and a library? Primarily in how you can use those and others, and what will be the nature of the code in the first and in the second case.





The difference is shown in the above diagram. The framework defines the structure that you have to fill out, and the library itself has some structure around which you build your code.

Of course, such a division into libraries and frameworks is not straightforward. Some components combine the features of the first and second: you call such a component as a library, but it has certain niches (for example, an interface) that you have to fill.

What is wrong with frameworks?

Having considered the above scheme, you can already see what problems can arise with frameworks. In this section, I will describe some things related to three such problems (and in the next section I will look at ways to solve them).

No frameworks are packaged.


Probably the biggest and most obvious problem of frameworks is that they do not fit together. If you have two frameworks, then each of them will have its own specific niche, which you will have to fill. But usually there is no way to insert one framework into another (and it’s usually unclear why one framework should be conditionally inside, and the other outside).

There is a different situation with libraries. You control them, so your program may well call many libraries. Of course, this presents some difficulties - you have to write more complex code around the end points of the libraries - but, as a rule, it is quite realizable.



Theoretical retreat

I'm not saying that the following considerations have any theoretical basis, but the frameworks are a bit like monads. If you are outside the monad, then you can “get inside” it with the help of the module. Then you can perform various operations inside the monad, but you can’t leave it anymore. Frameworks are similar to such monads.
It is well known that assembling monads is difficult (like frameworks). If you have monads M1 and M2, then they can be joined using the operation M1 (M2 α) → M2 (M1 α), i.e. interchange the ambient and ambient monads. Is it possible to do something similar with frameworks?


Frameworks are hard to explore.

Another major problem with frameworks is that they are difficult to test and investigate. In F #, it is very useful to load the library into F # Interactive, to try to run it with various input options and see what the library does. For example, you can use the Suave web development library to start a simple web server, like this:

//        #r "Suave.0.25.0/lib/net40/Suave.dll" open Suave.Web open Suave.Http //   -,     hello startWebServer defaultConfig <| fun ctx -> async { let whoOpt = ctx.request.queryParam "who" let message = sprintf "Hello %s" (defaultArg whoOpt "world") return! ctx |> Successful.OK message } 


This fragment loads the library, and then calls startWebServer with the default configuration and function for processing requests (the function receives the who query parameter and displays a greeting).
This practice is very useful, as it allows the user to quickly experiment with the library.

Try calling startWebServer with different parameters and see what it does (or, in the case of other functions, what it returns).

Theoretical retreat

The difference between libraries and frameworks is in many ways similar to the one that exists between a function call and the need to specify a function as an argument:
lib: τ1 → τ2 (library)
fwk: (σ2 → σ1) → unit (framework)
In the case of library, you will need to create the value τ1, so that you can call the lib function. Sometimes the library provides you with other functions that create τ1 (in this case, you just need to find the first function from such a chain and call it). When writing code interactively, you can try to set different values ​​of τ1, run the function and see what it returns. This way you can easily explore the behavior of the library (and how to use it to achieve what you need). In addition, in this case, testing code using libraries is simplified.

In the case of the framework, the situation is more complicated. You have to write a function that takes σ2 and produces σ1. The first problem is that you do not quite know what value of σ2 you will receive in different situations. In an ideal world, “invalid values ​​are not representable,” but in reality you want to start writing such code that, first of all, handles the most common cases. It is equally difficult to understand (and investigate) what values ​​of σ1 you must give in order to achieve the desired behavior.


Now, if you go back to my example with Suave, the reader may have a question: is it a library (we call a function) or a framework (specify a function that needs to be called). In fact, the above example demonstrates both aspects. As will be shown later, this version of the framework framework is not so bad (see the sections on callbacks and async below).

The frameworks define the organization of your code.

The next problem with frameworks is that they define the structure of your code. A typical example of such a case: we work with a framework that requires inheriting from a certain base class and implementing specific methods. For example, the Game class in the XNA framework looks like this (I know that XNA is dead, but this pattern also applies to other similar frameworks):

 class Game { abstract void Initialize(); abstract void Draw(DrawingContext ctx); abstract void Update(); } 


It is assumed that in Initialize you will load any resources that may be required in your game; Update is called repeatedly to calculate the next state, and Draw is called when the screen needs to be updated. The interface is clearly focused on the imperative programming model, so you will get something like this, which is shown below. Here we write a stupid version of the game in Mario, where Mario just slowly goes from left to right:

 type MyGame() = inherit Xna.Game() let mutable x = 0 let mutable mario = None override this.Initialize() = mario <- Some(Image.Load("mario.png")) override this.Update() = x <- x + 1 override this.Draw(ctx) = mario |> Option.iter (fun mario -> ctx.Draw(x, 0, mario)) 


The framework structure as such does not contribute to writing beautiful code in it. Here I just made the most straightforward implementation. The variable field x corresponds to the location of Mario, and mario is the option value for storing the resource.

You could argue that in C # such code could be more beautiful (for example, I had to use the option value, since all F # fields must be initialized), but this is true only if you completely ignore the check. In fact, using the option value here, we make the code more secure (since we cannot accidentally use mario in Draw if we have not initialized it). Or does the framework ensure that Initialize will always be called before Draw? How do we know this?

How to avoid the "smells" of frameworks

I hope I could convince you that writing libraries is better than building frameworks. But so far I have not given any specific advice on how this is done. In the rest of the article we will look at a couple of specific examples.

Support interactive research

Even if you are not writing a library in F #, you should use F # Interactive so that you can use it interactively! Not only is the F # language perfectly suited for documenting the library , but also by writing an interactive script, you can be sure that it will be very easy to call your library (if you are working on the .NET platform, then there is another option - work with LINQPad ).

I will illustrate my reasoning with two examples. The first code snippet shows how you can use the F # Formatting library to convert the documentation directory containing the F # script files and Markdown documents into an HTML file, or how to process a single file:

 #r "FSharp.Literate.dll" open FSharp.Literate //    Literate.ProcessDirectory("C:/demo/docs") //     Literate.ProcessMarkdown("C:/demo/docs/sample.md") Literate.ProcessScriptFile("C:/demo/docs/sample.fsx") 


The point is that you need to refer to the library, open the namespace and find the type of Literate as an entry point. By doing this, you can use the "." and see what you have!

I think all good libraries should support this practice. As another example, let's take a look at FunScript, which converts F # code into JavaScript. As a rule, it is used as part of a web framework, but it works fine by itself. The following snippet generates JavaScript code for a simple async loop, which increments the number on the page every second:

 #r "FunScript.dll" #r "FunScript.TypeScript.Binding.lib.dll" open FunScript open FunScript.TypeScript Compiler.compile <@ let rec loop n : Async<unit> = async { Globals.window.document.title <- string n do! Async.Sleep(1000) return! loop (n + 1) } loop 0 @> 


Again, we simply refer to the library (in this case, DOM bindings), and then call one function — the compile function accepts the F # quote. Having discovered this, you can try out for yourself what things she can handle! The previous example shows beautiful support for F # async {...} and bindings that give you access to the DOM.

Use only simple callbacks.

When I talked about frameworks in a theoretical digression above, I noted that the framework, in essence, can be called any construct that takes a function as an argument. Do I mean that you should not use higher order functions? Of course not!

Compare the following two simple fragments — in the first, standard functions are used for processing lists, and in the second, a certain input is read (using the first function), which is then validated and processed (using the second function):

 //      [ 1 .. 10 ] |> List.filter (fun n -> n%3 = 0) |> List.map (fun n -> n*10) //      ,  , //          readAndProcess (fun () -> File.ReadAllText("C:/demo.txt")) (fun s -> s.ToUpper()) 


There are two differences between the first and second examples. When working with list processing functions, we always specify only one function as an argument. Moreover, such functions should never maintain state.

In the second case, two functions are indicated. In my opinion, this is a sign that the function is more complicated than it should.

Secondly, readAndProcess obliges us to return the state of the string from the first function, and then accept the string as input for the second function. This is another potential problem. What if we have to transfer some other state from the first function to the second?

Of course, here I am considering a simplified case, but let's see what can happen inside readAndProcess. This function can handle some exceptions and first check the validity of the input, and only then call the second argument:

 let readAndProcess readInput processInput = try let input = readInput() if input = null || input = "" then None else Some(processInput input) with :? System.IO.IOException -> None 


How could this abstraction be improved? First of all, this function actually solves two problems. First, it handles exceptions (rather stupidly, but this is a learning example). Second, it validates the input. We can divide it into two functions:

 let ignoreIOErrors f = try Some (f()) with :? System.IO.IOException -> None let validateInput input = if input = null || input = "" then None else Some(input) 


Now validateInput becomes the most common function that returns Some if the input was valid. The ignoreIOErrors function still takes a function as an argument — in this case, it makes sense, since exception handling is a typical example of the Hole in the Middle pattern. With the help of new features you can write:

 ignoreIOErrors (fun () -> let input = File.ReadAllText("C:/demo.txt") validateInput input |> Option.map (fun valid -> valid.ToUpper() )) 


If you try, then here you can keep within three lines, but the code is a little longer and a little clearer.

In my opinion, this is a plus, since you see what is happening (and you can start with an interactive call validateInput!) Also, if you like the readAndProcess function more, this is also good - you can easily define it using the two above functions ( but not vice versa!) So, your library can provide a multi-level abstraction, as discussed in my previous article . But if we provide only high-level abstraction, it will limit our capabilities.

In summary, the transfer of functions as arguments is perfectly acceptable, but be careful. If the function takes as its argument two or more functions, then this is probably not the best low-level abstraction. If the functions passed as arguments must separate and transmit some state, then you should definitely provide an alternative (in case the caller needs something other than the “standard” state transfer).

Inverting callbacks using events and async

Speaking about how frameworks affect the organization of your code, I cited a simple game engine as an example. What could be done differently, so that you would not have to use variable fields and implement a specific class? In F #, you could use asynchronous workflows and an event-oriented programming model.

The situation is complicated in those languages ​​where there is nothing like computational expressions (as well as iterators allowing to simulate such a functional ), however C # supports await, F # has computational expressions, Haskell has do notation, and in Python, you can abuse it generators.

The idea is that instead of writing virtual methods that you need to implement, we provide events that are triggered when you need to perform an operation. So, the interface for our Game class might look like this:

 type Game = member Update : IEvent<unit> member Draw : IEvent<DrawingContext> member IsRunning : bool 


When working with F # async, we can write code differently. Returning to the original premise of comparing frameworks and libraries, we can achieve complete control over everything that happens! The following example first initializes the resources and the Game object, and then implements the loop (using recursive async blocks), waiting for the Update or Draw event using the AwaitObservable method:

 //     let game = Inverted.Game() let mario = Image.Load("mario.png") //  ,      let rec loop x = async { if game.IsRunning then let! evt = Async.AwaitObservable(game.Update, game.Draw) match evt with | Choice1Of2() -> //   'Update' return! loop (x + 1) | Choice2Of2(ctx) -> //   'Draw' ctx.Draw(x, 0, mario) return! loop x } //  Game  x=0 loop 0 


Of course, absolute control cannot be achieved, since we do not know when we will receive calls from the system to update the game state or redraw the screen. But we can fully control the initialization of resources, check when the game is playing, and wait for an event.

The key point here is the use of async {...}. We can use AwaitObservable to order: "resume calculations when Update or Draw is required." When an event occurs, we perform the necessary action (update the state in line 12 or draw Mario in line 15), and then continue. The most pleasant thing in this case is that such code can be easily extended to produce more complex logic - see, for example, the article by Phil Trelford. Another way to implement these properties is to use F # agents , which gives you similar control over logic.

So now we control everything, but have we achieved much? If you are not used to F #, then, most likely, the above code will seem confusing to you. The main idea is that, reversing the control, we can easily write our own abstractions. Here we come to the final stage ...

Use multiple levels of abstraction.

As I wrote in a previous post , the library should provide several levels of abstraction. The Game type I used in the previous snippet is a low-level abstraction; it is useful if you want to build something intricate, while giving you complete control. But in other cases, the game can really consist of a couple of functions: “update” and “draw”.
This is done without difficulty, since we can simply take the previous code snippet and extract several parts into arguments:

 let startGame draw update init = let game = Inverted.Game() let rec loop x = async { if game.IsRunning then let! evt = Async.AwaitObservable(game.Update, game.Draw) match evt with | Choice1Of2() -> return! loop (update x) | Choice2Of2(ctx) -> draw x ctx return! loop x } loop init 


The startGame abstraction takes two functions as arguments, plus the initial state. The update function updates the state, and the draw function draws it using the specified DrawingContext context. Thus, we can write our game in “Mario” in just four lines:

 let mario = Image.Load("mario.png") 0 |> startGame (fun x ctx -> ctx.Draw(x, 0, mario)) (fun x -> x + 1) 


If you carefully read the entire post, you can ask: Do I not contradict myself here? Didn't I write above that higher-order functions that accept multiple functions (especially if they share a state) are perverse frameworks? Yes, I said that! But let me clarify this point:

It is quite possible to have a convenient operation that takes a couple of other functions as a high-level abstraction, but it should have a simpler and more explicit alternative!

You can take the four lines above, look at the definition of startGame and convert them to the 14 lines of code that we have already seen above (excluding comments). That is, you should be able to gain control by placing one (not very deep) stage under the hood. This practice differs from building brittle scaffolding on top of a badly crafted library, which sometimes has to be resorted to to write beautiful code.

Develop composable libraries

As mentioned above, one of the main reasons why libraries should be written, rather than frameworks, is the composability of libraries. If you have complete control over this process, you can choose which libraries to use to solve which part of the problem. Sometimes it is not easy, but with libraries you at least have a chance!

I suppose there is no universal recipe for creating libraries that fit well. It is probably important to note the following: your types should provide all the important information that other libraries would need (for similar purposes) if the need arose to create a similar data structure.

A good example of this kind is FsLab , a package that combines a number of F # packages for working with data (including Deedle , Math.NET Numerics, and others). The FsLab package comes with one script that bundles a number of other libraries together (the source code is here ).

Two simple examples from the file are functions that perform the conversion from matrix to frame (Matrix.toFrame) and in the opposite direction (Frame.toMatrix):

 module Matrix = let inline toFrame matrix = matrix |> Matrix.toArray2 |> Frame.ofArray2D module Frame = let inline toMatrix frame = frame |> Frame.toArray2D |> DenseMatrix.ofArray2 


The solution here is quite simple, since both the Deedle frame and the Math.NET matrices can be converted to a two-dimensional array and back, so we just have to go in the array from one element to another.

It looks very simple, but I see the essence in the following: no matter what your library does, you should make every effort to have this library with others (or replace certain components in it if they don’t like it!).

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


All Articles