This is my second attempt to start a conversation about this feature. My first attempt might have failed because of a rather missleading title that I chose in order to get some attention:
Algebraic effects are among the most interesting features that in a couple of years everybody might be talking about. You can already get a glimpse of what is coming here:
or if you prefer to watch:
For those of you who want to read it here: what are algebraic effects and how might Swift benefit from them?
Very generally speaking, algebraic effects are a special kind of annotation. They typically appear inside function declaration, but it is straightforward to generalize them so that they can annotate arbitrary variables in your code. However, unlike property wrappers, you would need some different annotation to read those variables or call those functions and you will need some special execution context.
Concrete example: Suppose, you write a function that does something random. As a good functional programmer, you know that nobody will read your documentation and people may even ignore the word "random" in the name of your function. So how can you make sure people are aware of the randomness "side effect" of your function? Answer: by having a "random effect" in the type signature of your function:
func getRandomVector(length: Int) random -> [Double]{
(0..<length).map{_ in getrandom Double.self}
}
This function cannot be called by accident without the programmer noticing. Inside the function, you will notice the new getrandom
"keyword". This indicates the use of some primitive code for the randomness effect, similar to the "throw" keyword inside throwing closures (that are called with try
).
Just like with throws
and try
, there are only two ways you can call a random
function: 1. inside another function annotated with random or 2. via special language constructs that allow you to convert a random function into a non-random function; these language constructs may require you to provide additional context like, e.g., a random generator.
In case 1, you will have to add another annotation, to make the randomness explicit:
func callsRandomVector(length: Int) random -> Double{
let randomVector = flip getRandomVector(length: length)
return randomVector.reduce(0,+)
}
Case 2 may be solved with a do-catch block:
func unsafeRandomCaller(){
do{
let randomVector = flip getRandomVector(length: 42)
doSomething(randomVector)
}
catch {continuation : (RandomGeneratorProtocol) -> Void in
continuation(UsualRandomGenerator())
}
}
In the above scenario, it will of course be necessary that the catch block is aware of all random effects that happen. For test purposes, you may want to override the randomness and actually provide some concrete value, so the catch blocks need to provide you with a continuation of the correct random generator type for each such event.
A different solution to the do-catch block may be a special function:
func unsafeRandomCaller(){
runRandom(RandomGenerator()){
doSomething(flip getRandomVector(length: 42))
}
}
This may look a bit more natural, however it is a bit tricky to get the types right.
Another example: Say, your function reads some more or less global information. However, this information is dependent on the app's configuration which is only available at runtime. So, you need to make absolutely sure the information has been loaded before you actually run this function. This also means that nobody thinks they can use this function to provide the information, i.e., the direction of dependency needs to be absolutely clear.
The solution is the dependency effect:
func solveHaltingProblem(goedelNumber: Int, input: Int) depends -> Bool{
let oracle = require Oracle.self //require being the primitive keyword for the dependency effect
return oracle.willProgrammStop(onInput: input, programmCode: goedelNumber)
}
(Note that whichever environment you tried to provide in above example, the code would still be semantically incorrect - unless you find a way to do computations well beyond what Turing machines are capable of).
In a way, this is just a generalization of the randomness effect. Having both of the above effects around may however be useful if you like self-documenting code. Like in the random
example, you will need a third keyword, e.g. read
, to call non-primitive depends
functions.
Another example: Say, you have a function manipulating a more or less global state. You know already your colleagues will hate you for this, as manipulating global state is generally frowned upon. But if we're honest for a moment, any useful program will have some kind of global state and all history of design patterns is just about making global state changes explicit so we don't shoot ourselves in the foot all the time.
Enter the state effect:
func setGlobalVariable(newValue: Int) mutatessharedstate {
mutate SharedState.myVariable = newValue
}
Note that, if you want to actually run this function, you have to inject a shared mutable state variable explicitly. That way, you control how global the access will be in reality. You may decide to run multiple functions in parallel that all try to mutate the "global" state, but really you give each of them their own copy.
Fourth example: Say, a function accesses some streams of events. For example, you wrote some long running process that asynchronously emits log data and eventually the message "I'm done". When the process is done, you want to start some other process using the result data.
This may be orchestrated by an observable effect:
//ProcessEvent and FinalResult would be custom types
func orchestrateProcesses() emits -> ProcessEvent<FinalResult> {
let messageStream = observe longProcess1()
switch messageStream{
case logMessage(let log):
return .logMessage(log)
case result(let result): //intermediate result
return observe longProcess2(input: result)
}
}
The above code can be easily understood by reading it from the top to the bottom - or from the bottom to the top. It's just a sequential computation - however, there's a catch. The code will not just run once, but many times! With the observe
keyword, we are actually registering an observer on longProcess1
, and longProcess1
may actually "return" multiple times. This could for instance be solved with a third keyword:
func longProcess1() emits -> ProcessState<IntermediateResult>{
for _ in 0..<100{
emit .logMessage("Not done yet...")
sleep(1)
}
return .result(...)
}
In the case of this effect, emit
ting and return
ing may actually be used interchangably, except that return
still has to appear exactly once on each code path (unless we have a void
function). Also, the emit
ted values must agree with the type signature of the function.
Note you can also have a simpler version of the above that will return only once. That version would be more suitable to create function-like code that looks like normal synchronous code but can run asynchronously without ever blocking.
One last example: Say, you are modelling a process that has checkpoints. On each checkpoint, some outside code will decide if and to proceed with the task or not. This may look like this:
func divide(_ lhs: Int, by rhs: Int) guided -> Int{
guard rhs != 0 else{
let defaultValue : Int = help DivisionByZero()
return defaultValue
}
return lhs/rhs
}
DivisionByZero
is a type conforming to some protocol that can be handled by guided
functions. A non-guided
calling function may then feature code like this:
do{
let divisor : Int = help GetSomeInt(suitableFor: "division")
let number = guide divide(1337, by: 0)
return "\(number)"
}
catch{(continuation: (((GetSomeInt) -> Int), (DivisionByZero) -> Int) -> String) in
return "Nope, not gonna touch this."
}
I would love if Swift had some way for developers to introduce custom effects - in addition to having some more built-in effects (like the already existing throws
).
At this point, let me admit something: The above ideas are probably inconsistent with each other. I don't have a detailed design yet how exactly the rules would be in Swift for custom effects, and I'm not exactly an expert on algebraic effects. I just wanted to make you as curious as I am and spark a discussion about that topic.
Here are some thoughts on what problems those effects solve and what they have in common. There are lots and lots of problems that can be solved with these effects. The above examples show that they all provide the following benefits:
- They introduce some special logic that depends on outside information.
- They hide a whole lot of complexity and boilerplate.
- They make inversion of control (and therefore testing) super handy and easy.
- They look way better than the prominent alternative from functional programming (see below) that you can already use in Swift.
For an effect to make intuitive sense, they need to obey some rules:
- As long as all your variables are immutable, it must not make a difference if you read your code from the top to the bottom or from the bottom to the top.
- Functions that don't use the effect must retain their meaning when called inside a function using the effect. In particular, chaining functions must work the same way inside effectful functions as ot does outside (the compiler will try to call as many chained "pure" functions as possible, but sometimes it might be necessary to "map" them to an effectful function).
func foo() someeffect -> Int{42}
must do the same thing asfunc foo() someeffect -> Int{runeffect 42}
(the compiler will warn you that no call actually uses your effect - and when running with optimizations, the compiler means it [whenever it can]).
The above rules roughly constitute what is called a "Monad" (the aforementioned functional alternative). It is well known that in order to properly represent a monad protocol (which may help to implement the syntax feature proposed here), Swift's type system needs to be extended quite a bit. However, you can already see a lot of monads in action in today's Swift: Optional, Result, Array and Promise are just some.
This time, I want to mention two alternatives in the original post before it is brought up in the discussion: Other languages have other designs to make monads "look good", i.e. look like usual imperative code. There is Haskell's do-notation and there are languages that solved the problem with for-comprehensions.
On the plus side, this is just one syntactical concept that you have to keep in mind, whereas with custom keywords, you have to learn a lot more. However, this is a one-size-fits-all approach. Those notations don't really make sense for each and every monad. Also, the custom syntax version to some extent bypasses the need of monad transformers.
It has been speculated that the availability of custom keywords may lead to a proliferation of them. However, with the do notation and the for comprehensions, we would still need to learn the names of certain monads if they proliferate. The overhead of learning 3 keywords instead of 1 class name is O(1)
, and if custom syntax makes monads a more shiny tool that is in wider use, I would welcome this very much. It is the community's responsibility to filter out less useful applications - but by popularizing this useful tool from functional programming, we may actually find additional useful examples that we hadn't thought of before.
I am looking forward to the discussion.