A sixty-year-old prisoner and a lab rat. F# on Godot. Part 2. Expressions

In the last part, I talked about adapting the Godot API to F#. Next, the plan was to figure out the general structure of the application, but I was faced with the need to close a serious gap in the public text corpus. So in this and subsequent parts I will explain something strange – how from an ordinary function through evolution a working program on Godot is obtained.

In my opinion, for most F# newbies, the tactical and strategic levels are in different universes. It’s like here in the local space we have FP, but in the global space it’s suddenly only OOP. It is, of course, good that we can glue two paradigms together, but it seems to me that this insurmountable wall on the border of the FP’s sphere of activity is not so insurmountable. Its existence is not due to objective reasons, but to a lack of experience.

Like many, I began my acquaintance with programming from school computer science lessons. There they gave us Pascalbut then we almost arbitrarily climbed someone onto Delphiwho's on C++. Extrapolating from my personal experience, most had this:

  • First we pull functions and pull arrays;

  • Then we “invent” records so as not to synchronize data between several arrays;

  • Next, we give the records behavior to make it shorter;

  • We inherit and override behavior so as not to mess with flags;

  • Inventing interfaces to avoid inheritance;

  • Mastering patterns, DI, etc.

That is, the average development vector is directed from procedural programming to object-oriented programming. It turns out to be some kind of typical progression along the technology tree in digital video, from the invention of the farm (memory cell) to OBHR (<вставьте свою любимую ООП-технологию>). Everything is generally clear and expected, short-term variations are possible, but new technologies will sooner or later need to fill the gaps. Unfortunately, when people get into FP, by and large they get a kind of procedural programming course on steroids without moving on to a real long-lived application. Because of this, the latter is prepared as best they can, exclusively through OOP.

We are not going to abandon OOP, especially in light of how widespread it is at the framework level. But I want to show how and where it naturally (and appropriately) arises within FP and F# in particular. I'll start by taking apart a small abstract function, building it up to the point where it collapses under its own weight, cutting it up and rebuilding it with a new mechanism, and then moving on to building it up again. This process will be repeated many times over several chapters and at the beginning it will not be tied to Godot (except for examples). Perhaps I should have put a couple of chapters in a separate series, but I did not dare to devote time to such a radical reshaping. For those who are already firmly on their feet and came here for the Godot adaptation, I suggest either being patient or fast forward 2 chapters. And those who are just starting their journey can rejoice, because in this chapter, as my testers put it, I’m openly lisping, trying to adjust your perception of the code in the right way.

Everything is an expression

Many introductions to F# mention that the vast majority of code in the language consists of expressions. But I can’t imagine what kind of experience you need to have in order to immediately understand the scale of the consequences of these words, because in most cases everything begins and ends with example analogies of the ternary operator from C#.

This topic is much broader, but I will also start with ternary, because we really don’t have it in its “usual” form. You can conjure it yourself, but there is no point in this, since an ordinary if:

let color =
    if block.Position = cellUnderMouse
    then Colors.OrangeRed
    else Colors.White

The funny thing is that in F# the construction if ... then ... else is an expression, and an expression always returns some value. Even when it “returns nothing” it actually returns an instance of the type Unit.

Unit (or unit) is a certain type, in my free interpretation, denoting achievable emptiness. It consists of mathematics and a big beautiful compiler hack, but in general we can consider it a singleton, an instance of which can be obtained through let instance = (). There is no data in it. All its copies/instances are equal to each other. Therefore, purely logically, if you know that it is posted somewhere (or will only be posted) Unitthen you know the value at this point in advance:

// Абсолютно детерминированный паттерн матчинг,
// ибо `let () = ()`,
// так что компилятор на него не пожалуется.
let () = GD.print "Эта функция реально возвращает `unit`."

Because of this, in most cases unit is not of interest and can be ignored:

let a =
    () // Игнорируется.
    () // Игнорируется.
    () // Игнорируется.
    GD.print "Что-то будет."
    42

// val a: int = 42

What is abnormal in relation to other types:

let a =
    () // Игнорируется.
    () // Игнорируется.
    42 // Делает предупреждение, так как скорее всего число оказалось здесь по ошибке.
    ()

// ... warning FS0020: Результат этого выражения имеет тип "int" и неявно игнорируется. Используйте "ignore", чтобы явно отклонить это значение, например, 'expr |> ignore', или используйте "let", чтобы привязать результат к имени, например, 'let result = expr'.
// 
// val a: unit = ()

Processed according to the same logic if ... then (without else). If branch then returns unitthen the compiler can independently “add” else ()to equalize the options:

if pointUnderMouse <> under.Point then
    pointUnderMouse <- under.Point
    dirtyAngle <- true
// else ()

Branch equality is a cool thing, but it, along with several other points, is the reason that we don’t have early return. We cannot write:

if not ^ canStand obstacles mapSize goal then None
// Продолжаем вычисления для остальных случаев.

We are required to write:

if not ^ canStand obstacles mapSize goal then None else
// Продолжаем вычисления.

Such if is not terrible if it occurs in the middle of sequential calculations. But more often than we would like, it is located deep in the matching, loop, etc., which is why we have to rebuild the entire algorithm. In this place, newcomers suffer because they do not have the necessary set of technologies (railway, rec etc.). However, with experience you will find that early return-s are important semantic anchors and you want to collect them in certain nodes of the system. This isn't always possible, but when it is possible, everyone benefits.

A method call is an expression, so it must return something, so F# assumes that all methods void from C# return unit. We also need to obj.ToString()And obj.ToString were expressions, so the parentheses in the first case are interpreted by the language not as a special language construct, but as the transfer of an instance unit V obj.ToString. Where possible, the conversion works in reverse, unit in C# will be interpreted depending on the context either as voidor as a lack of parameters. Thanks to this versatility, we have no division into Action And Funcsince both can return unit:

System.Action = System.Func<unit> = unit -> unit
System.Action<int> = System.Func<int, unit> = int -> unit
// Равенство по смыслу не распространяется на типы / экземпляры. Типы, а значит и экземпляры, всё-таки разные.

To be precise, we simply don’t need Actionsince it is a subset Funcwhich can be expressed with a regular alias. Same with “asynchronous functions”. Neither 'a Async (= Async<'a>), nor 'a Hopac.Job with its many heirs there is no need for a “blank” version, as happened with 'a Taskto whom to present unit Task it was necessary to invent an additional layer in the form of a non-generalized Task. This versatility extends further to user code, so that when transferring some algorithms and libraries from other languages, you may find that sometimes the number of methods and types is reduced by half.

At the same time Unit still remains a tangible type, it can be stored individually and in collections:

let a = [
    ()
    ()
    ()
    ()
]

// val a: unit list = [(); (); (); ()]

This is, of course, a degenerate case, but imagine that instead of a list we are talking about a channel for transmitting messages between actors. Even if you are writing a simple trigger and do not plan to transfer any data, you still have a universal interface for the very fact of exchange. There is one limitation left, unit impossible to identify from objbecause box () equals null. The situation is similar Measure And Option.Nonewhen adding to ECS, play it safe and use these values ​​only within types with clear identification.

Ignore

Expressions have a new problem that is not specific to C#. If you call a method that returns a result and gives a side effect, then chances are you only need the side effect. The problem is that you must process the result anyway.

For example, by clicking the mouse we want to remove or add an obstacle block to the field:

if set.Contains block then
    set.Remove block
else
    set.Add block

AND set.RemoveAnd set.Add return trueif the operation was successful. Obviously, when put in if provided both branches are returned trueso this one bool useless as unit and it just needs to be melted somewhere. Any result you can always put it in an unnamed variable:

let _ =
    if set.Contains block then
        set.Remove block
    else
        set.Add block

This is a universal method that is worth remembering when you are working with unnecessary IDisposable:

use _ = new DisposableSideEffect()
// ...

Or with builder (CE):

job {
    // ...
    let! _ = // MySystem
        MySubsystem.init // MySubsystem Promise
    // ...
}

But in most cases let is an excessive measure. There is a function in the global scope ignore : 'a -> unit. As the name suggests, it simply ignores its argument, so the example can be rewritten as:

if set.Contains block then
    set.Remove block
    |> ignore // : unit
else
    set.Add block
    |> ignore // : unit

Can't be overstated ignore and treat it as a universal keyword. This is just a function that doesn't know anything about builders, so it won't convert 'a Job V 'unit Job:

job {
    // Ошибка, так как специальная обработка `unit` в билдере отсутствует.
    do! client.UploadFile file
        |> ignore

    // Нет ошибки, но и загрузки файла не происходит,
    // так как мы просто выкинули 'FileId Job`, а не запустили её.
    do client.UploadFile file
        |> ignore

    // Всё ещё работает, но может не подойти по эстетическим соображениям.
    let! _ = client.UploadFile file

    // ...
}

For these purposes, the core builder type usually has its function of a similar property, such as:

  • Async.Ignore : 'a Async -> unit Async

  • Job.Ignore : 'a Job -> unit Job

  • Alt.Ignore : 'a Alt -> unit Alt

  • Stream.mapIgnore : 'a Stream -> unit Stream

  • etc.

Which allows you to write code like this:

job {
    do! client.UploadFile file // : FileId Job
        |> Job.Ignore // : unit Job
}

In practical terms there is not much difference between let! _ And do! ... |> Job.Ignore. This identity must be remembered, because it also means that both options will wait until completion. Job. No “fire and forget” from Job.ignore not expected, there are separate functions like (queue, start and their variants). If we draw an analogy with C#, then:

  • let! And !do – analogues await;

  • Job.Ignore – way to ignore the result of the work, but not the work itself;

  • queue, start, Job.start etc. – a way to ignore the work (“fire and forget”).

The last two items are optional in C#, but in F# they are forced if the first item is not selected. That is, you must either explicitly integrate (await-it) Job-in the builder, or obviously fuse it despite Job.Ignore. This responsibility arises through expressions, so the above rules apply to all builders (async, task etc.). And this is an extremely useful property. It costs a second of working time, and in return it completely saves us from some common mistakes.

By the way, if let! is responsible for Job “with result” and do! for Job “without result”, then we seem to have two different syntactic constructions, which lead to the idea that at least in builders F# returns to the “usual” dichotomy void VS все остальные. This is easy to check in practice, since builders in F# can be written independently (here they explain in detail how). It would be logical to expect a separate code for each keyword. However, if we look at the builders' contract, we won't find anything like that. He doesn't seem to be aware at all that he needs to deal with do!. This is explained by the fact that F# considers do! just sugar over let! () =:

job {
    let! () = someUnitJob
    do! someUnitJob
}

I go around in circles “looking for a plot twist” for a reason, but so that you pay attention to how the concept unit reduces infrastructure costs. Without it, the number of required methods in the builder would increase by 4 times (x2 for input, x2 for output). I suspect that this point was one of the reasons why C# abandoned builders in favor of keywords async/await. In F# there is no such problem, because of this we move much more easily to creating individual infrastructures and very rarely give birth to common ones.

They try to attribute this behavior to differences in psychology, but I see this only as a consequence of the material environment. We really can, so we don’t need someone else’s, C#ers can’t, and that’s why they are so close to Microsoft. Someday we will get to the point of analysis Godot.Callableand there this moment will come up again.

If you're being picky, it's worth mentioning that F# is still more tolerant of do! and allows it to be the last expression in the builder. let! () = – is deprived of such a privilege and after it at least some kind of activity is required:

async {
    let! () = someUnitAsync
    ()
}

I haven't tried it, but most likely, with due diligence, you can write code without any do!. This hardly makes sense, since do! is tritely shorter and with such a strict “linear” definition cannot be a threat. If someone is itching to “look for rats in the house,” then I would first of all take a closer look at the careless ignore on curried functions.

This thing can really shoot off your leg.

Scope and side effects isolation

F# has a keyword do (no exclamation point). By a wonderful coincidence it means let () = (no exclamation mark). There are places where do must be written, but in most cases it is omitted in the same way as explicit processing (). It’s simple, even too simple, so under a certain set of circumstances you may not know about its existence long enough for you to no longer be perceived as a neophyte (there have been cases).

Strength do revealed in special circumstances. First, it acts as an explicit typer for the compiler. Here f function int -> string:

let g f = [
    f 42
    "Item"
]

And here int -> unit:

let g f = [
    do f 42
    "Item"
]

Has the same quality do!although its typing properties may be spoiled by shortcomings of a particular builder.

Secondly, and most importantly, do can hide side effect variables by isolating the local scope. Let’s assume that we did need “fire and forget”. For example, during development we want to display some additional information on the screen, which in the context of tiled worlds could be a map without the fog of war or with goals and decisions of artificial intelligence. This can be implemented as a switchable option for the main scene, but it is much more convenient when such information is rendered in a separate window, because in this case new buttons can be attached to the new version of the map without complicating the main scene. All communication of such a window can be set at the moment of spawn, after which its existence can be forgotten. The following code will create a new window along the right border of the main one and give it a width one third of its parent:

// По умолчанию окна встраиваются в основное.
// Внешнее существование включается через:
// window/subwindows/embed_subwindows=false

let mainWindow = this.GetWindow()

let size = mainWindow.Size
let debugWindow = new Window(
    Title = "Debug"
    , Transient = true
    , Position = mainWindow.Position + size.X_
    , Size = size.With(X = size.X / 3)
)

debugWindow.AddChild ^ DebugView.create model

this.AddChild debugWindow
debugWindow.Show()
mainWindow.GrabFocus()

Typically, configuration requires more steps, which we will touch on in subsequent articles, but here we already have the key categories for scope:

  • The required bindings are below: model And mainWindow;

  • Unnecessary below bindings: size AnddebugWindow;

  • A set of local actions accompanying spawn: AddChild, Show, GrabFocus.

This code is located at the beginning of the method _Readyand if everything is left as is, then size And debugWindow will remain available throughout its entire duration. Sometimes this works to our advantage, but in most cases it is nothing more than pollution of the general environment. If we run this code under dothen the isolated variables will not be included in the scope, although the overall effect will be identical:

let mainWindow = this.GetWindow()

do  let size = mainWindow.Size
    let debugWindow = new Window(
        Title = "Debug"
        , Transient = true
        , Position = mainWindow.Position + size.X_
        , Size = size.With(X = size.X / 3)
    )

    debugWindow.AddChild ^ DebugView.create model

    this.AddChild debugWindow
    debugWindow.Show()
    mainWindow.GrabFocus()

If this code were repeated in several places, it would have to be included in a function. However, much more often such actions must be performed in a single copy, and in this case do-writing may be a good alternative due to its greater readability and change-friendliness:

  • do retains access to external scope bindings. A block knows their type, so unlike a function I don't have to re-explain to the compiler what it is model. This may sound strange now, but there are situations where the type of a potential parameter is so difficult to express that we resort to very clever refactorings in an attempt to specify it indirectly;

  • do Destroys bindings (not objects) after closing. size And debugWindow after it they simply do not exist;

  • Visually do clearly outlines the area of ​​responsibility, so that a trained eye can automatically:

    • Or cut off such blocks as uninteresting;

    • Or focus on one of them, if it becomes clear that the solution to the problem is hidden here;

  • If some kind of binding inside is needed somewhere else, then it can be quickly pulled out of the block into the general scope (or put back in if such a need disappears);

  • And at the same time, such a block still remains a good template for a function.

All this fuss with do may seem insignificant, but it has a serious impact on development speed. If you have ever encountered initialization code in samples of graphonia libs (hi Veldrid-y), then you could find epic canvases of 800 lines like:

let <property><part1> = загрузка данных
let <property><part2> = загрузка данных
// Часто наряду с самим <property><part_> используются его части,
// например, <property><part_>.Length/Size и т. п.
this.<property> <- комбинирование <property><part1> и <property><part2>

// Повторить до посинения с небольшими непараметризуемыми отклонениями.

Some properties and parts from different blocks may be connected to each other according to plan, but it is almost impossible to protect yourself from accidental connection with such an open record. Block do almost eliminates such connections due to forced isolation:

do  let <part1> = загрузка данных
    let <part2> = загрузка данных
    this.<property> <- комбинирование <part1> и <part2>

Local variable names can be simplified to suit the context. Then the same names in different contexts will emphasize their similarity to each other (if any). In contrast, rare names from the general scope will stand out from the general series even at the level of peripheral vision.

By the way, do supports nesting for controlled distribution, so hierarchies like:

do  let localSharedPart = ...
    
    do  let <part1> = ...
        let <part2> = ...
        this.<property1> <- ...
    
    do  let <part1> = ...
        let <part2> = ...
        this.<property2> <- ...

Factory insulation

All this is quite intuitive and happens almost seamlessly. Even C# can achieve similar behavior using additional curly braces. However, the parentheses do not expose the result as they do in our case. It should be understood that the following code in the body of the function, despite its multi-line nature, is just another expression:

f x
g y
h z

The statement looks shaky, it’s still not a chain of pipes (|>), so let's use the abstract syntax tree (AST) model from the compiler. In it, the concept of “expression” corresponds to the type SynExpr. This is a large discriminated union (DU) in which each case corresponds to some type of construction in the expression. Specifically these three lines will be expressed like this SynExpr:

SynExpr.Sequential(
    f x
    , SynExpr.Sequential(
        g y
        , h z
    )
)

// f x = SynExpr.App(SynExpr.Ident f, SynExpr.Ident x)

And even when we shove it all in dothen we just pack SynExpr.Sequential V SynExpr.Do. As a result, we see that at all stages the work is carried out with the same type of nodes. There is no transition to a fundamentally different space in order to lay out a set of several SynExpr. Everything is resolved within the same tree, even though the contents of the rows are not explicitly related to each other.

From which it follows that the compiler sees do 42 and rears up not because of the syntax. He knows for sure that let () = 42 is a false statement and that is why he is yelling at us. But if there is something more suitable on the left side, then the error will not occur:

let debugWindow = 
    let size = mainWindow.Size
    new Window(
        Title = "Debug"
        , Transient = true
        , Position = mainWindow.Position + size.X_
        , Size = size.With(X = size.X / 3)
    )

The same move is true for mutations:

this.<property> <-
    let <part1> = загрузка данных
    let <part2> = загрузка данных
    комбинирование <part1> и <part2>

Sometimes the last case looks too loose, so it can be written “the old fashioned way” through do or wrap it in brackets:

this.<property> <- (
    ...
)

Parentheses, within reason, can reduce the risk of misreading. Use them every time the compiler doesn't understand you:

new Window(
    Title = "Debug"
    , Transient = true
    , Position = mainWindow.Position + mainWindow.Size.X_
    , Size = ( // Без этих скобок код некорректен.
        let size = mainWindow.Size
        size.With(X = size.X / 3)
    )
)

Parentheses are the only way to isolate element generation within a list builder ([], [||], seq {} etc.). Even obvious yield will not help in this matter:

let items = [
    42
    32
    (
        let a = 2
        a * a + a
    )
]

Parentheses can even be used instead do-block, but personally I find it inconvenient to refactor such code. The compiler parser almost always considers parentheses as key reference points, so removing/adding one parenthesis often results in completely inadequate file markup below the editing area. It can be difficult to find a soul mate in the absence of clues, so dancing begins with ctrl+z etc. Therefore, parentheses remain only for those places where they are needed (including for readability reasons). Everything else should be resolved through one-way tokens.

After publishing the previous chapter, I was asked why I put a comma (between parameters) at the beginning of the line, and not at the end, as is customary in most codebases. I usually explain the approach using specific refactoring examples, but they can all be boiled down to the concept of one-way tokens. Bundle запятая + имя + равно is located in one place, which makes it much easier to move between nodes. Apparently, it also works better for the parser, since it breaks the coloring much less often.

The analysis of options for laying out expressions will be partially continued in the next part, but in general I’ll stop here and admit that after all these years I’m still faced with options that are new to me. Most of the time this makes me happy, but it also means that it is almost impossible to memorize everything. Therefore, get acquainted with the base, and then just try, look at moves in other people's repositories and combine. Reality will suggest good options, and also highlight the inconsistency of some theories in the context of F#. For example, criticism of the “pyramid of doom” should be subjected to serious revision, since the designated “doom” does not always arise and depends on some other criteria.

Long interim conclusion

There is a well-known article that a person can keep no more than 7 (+-2) objects in local memory. It is appealed to with or without reason, including to defend frankly bad decisions, so the announced rule is already acquiring negative connotations. I suspect that, as usual, in the source code the boundary of the scope of applicability (and especially inapplicability) is set much more specifically, and the public formulation is a very rough retelling of the original. However, it is difficult for a person to navigate a large pool of peer values. This can be dealt with in different ways, but in my opinion, F# has been so successful in this matter that if it weren’t for the amorphous nature of the topic, he could have built his own PR company on it.

Management and its cronies often misjudge Junes' love of pipes (|>) as a manifestation of youthful enthusiasm, they draw false analogies from youth subcultures to proverbs about a fool, a forehead and God. The initial stage is indeed accompanied by not too harmful, but still excesses. However, you need to understand that such cravings are not caused by a reaction to something new, but by a sharp and quite tangible increase in efficiency, not accompanied by noticeable side effects.

The leap is so serious that it is time to talk not about efficiency, but about power (to do something worthwhile in one hand). It comes from the fact that pipes are not about elegance, but about the ability to get the job done and not leave marks. Typically, overcoming or clearing these tracks takes up a significant portion of a newbie's operating costs. And when this problem is removed, the person begins to act much faster and more aggressively. This is an appropriate reaction, and I tend to be more suspicious of those who do not show it.

When I’m poking around data in a REPL and discover an anomaly, I repeatedly wedge myself into the pipeline a couple of lines before the finish line and start throwing in new filters, queries and views. After an hour of such classes, I have a conveyor belt of 20 lines, followed by another 100 lines of some invalid rubbish. Moreover, the latter is not always garbage, and some of it should be corrected and thrown into tests. The example is not entirely fair, since here I explicitly select executable code, but it shows how convenient and important it is for us to have a stable, incrementally growing base.

Expressions are the same base. It stands on the same field, but unlike pipes it works well with almost the entire arsenal dotnet. Artificial fragmentation of expressions, together with local functions, closures, module bindings, and classic class members, form another densely marked discrete range through which we can navigate almost intuitively.


We will continue to deal with expressions for another chapter, but if in this one we divided the scope and hid objects according to their location in space, then next time our attention will focus on the time of their existence.

Author of the article @kleidemos


The UFO flew in and left a promotional code here for our blog readers:
— 15% on any VDS order (except for the Warm-up tariff) – HABRFIRSTVDS

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *