Why pipeline does it right?
When I first learned F#, one design I didn’t get is that pipeline operators is used far more often than dot( . ). For example, for a C# expression like x.Select(n => ...) , you have to write x |> List.map (fun n -> ...) in F#, which looks verbose at the first sight. But after I’ve done more programmings and more thinkings, I figured out that the dot is abused in most of the languages and the pipeline does it right. Here is my thought.
The problem of DAP (dot-abusing programming)1
I used to think that a dot means applying a certain operation to the object. That’s wrong. A dot means providing a certain operation by the object. The problem of DAP is that it couples the operation provider and the applied object.
Let’s use dependency-injection as the first example. In DAP, dependency injection is considered as a “pattern”. When we want to apply an operation to an object, we use obj.operate(args) , but if the operation is an injected dependency, we have to rewrite it like dependency.operate(obj, args) . In F#, on the other hand, it’s very natural. We use obj |> Module.operate args , and if we want the operation injected, we just change to obj |> dependency.Operate args , almost identical. The reason behind this is that, in DAP, the operation provider and the applied object are coupled, so if we want the operation provided by a different dependency object, you have to write it in an unusual way that is considered a “pattern”. But if we decouple those and use the pipeline operator, we can write it like obj |> Module.operate args . Now the operation is provided by the Module instead, and if we want a dependency object as a provider, we just use the object as the provider and write it like obj |> dependency.Operate args .
My second example is the “decorator pattern”. In DAP, if you want to extend the operations of an existing class, we need to create a wrapper class called the “decorator” then define the extended operations as the decorator’s methods. We need the decorator pattern in DAP because it couples the operations provider and the applied object, and the decorator class is the one that has both of them. But in F#, we just define it as a function and pipeline to it. Just this simple.
The main reason that we mistakenly couple the operation provider and the applied object, in my opinion, is that providing an operation that is related to itself is a very common case. For example, for a string s , s.Contains(a) should not be interpreted as “applying the operation ‘Contains’ with a as the argument to s”, but “Apply an operation ‘Contains’ provided by s (which returns whether s itself contains a string) to a”. The Contains is resolved by the type of s (at compile time or even at runtime if the method can be overridden). But if we write it as String.contain a s , it now is interpreted as “applying the operation ‘Contains’ provided by String module (currying) to a then to s”. The contain is now resolved by the String module, and the type s and a can be inferred from the function. The difference might be subtle, but we shouldn’t mistakenly consider them as the same and couple them.
The problem of DAP in dynamic-typing language.
DAP may cause more troubles in a dynamic-typing language, as in dynamic-typing language, operations provided by an object are hard to be determined statically. If you come from a static-typing language to a dynamic-typing language, you might find that the auto-completion of the methods doesn’t work as expected, go-to-definition doesn’t work as expected, etc. You might think it’s a problem of dynamic-typing. It’s not true. It’s the problem of DAP.
If you’ve used Elixir, you might find out that all those features work fine despite being a dynamic-typing language. Because in Elixir, we use obj |> Module.operate instead. Auto-completion is easy, just list every function in Module. Goto-definition is easy, just go to the operate function in Module . If we decouple the operation provider and the applied object, writing dynamic-typing language can be more fun.
Implicit Operation Provider
In some languages like C#, Kotlin, and Scala. They use an “implicit operation provider” mechanism. In C# and Kotlin it’s called extension function, in Scala it’s called implicit.
Let’s use C# as the example. In C#, if you import a static class that contains extension methods, you can extend extra methods of a class. Calling the extension method on an object is equivalent to using the static class as the operation provider and the object as the applied object.
This mechanism does solve the problem, but I don’t really like this approach for the following reasons.
First, it overloads the dot syntax, volatiles the Single-responsibility principle of the language grammar. In these languages, obj.operation may not only mean “applying the operation provided by obj “, but also mean “applying the operation provided by an implicit static class”.
Second, implicit requires assumption. In the opening example x |> List.map (fun n -> ...) , List is what provides the map operation and x is what the operations apply to. In F#, this version of map is eager (returns a new list immediately). If you want it to be lazy (returns a Seq which will do the mapping when being enumerated) instead, you can change to x |> Seq.map (fun n ->) . In this case, the map operation is provided by a different package Seq , and hence having different behavior. But in C#, you just write obj.Select(n => ...) . Is it lazy? We assume it is as that’s how standard Linq library does. The problem is, when there are a lot of 3-party libraries, I’m not sure I’m confident to assume. In fact, Scala is especially known that the programmers have trouble figuring out what “implicit” is.
-
I will avoid using the term “Object-oriented programming” because this problem exists in some languages that claim themselves “Functional programming language” while it doesn’t exist in some languages that are considered the “OO done right”. ↩︎