I wanted to leave the title of this issue rather generic so we can discuss all alternative options to solve this.
Proposal: Monad-ish
My proposal is to leverage the type system in a way similar to Haskell's monads: expressing side effects (both global access as well as true "effects") in terms of wrapper types. Whether or not they are actual mathematical monads is not as important to me, but I might use the term occasionally due to my familiarity with it and lack of a better term (for now).
In Haskell, if a function accesses stdout
, its function signature must reflect this. As every function in Haskell contains at most one expression, this is easy: operators over monads must operate on the inner types, and you must handle the type compatibility throughout the entire function.
An example:
main :: IO ()
main = putStrLn "hi"
This means that the function accesses I/0 (stdout) and returns nothing (()
). If you get a number from IO, it might look like this:
readNum :: IO Integer
readNum = prompt "Please enter a number"
This can, in some sense, be read as "the Integer returned by readNum
has been polluted by outside state". From here on out, any interactions with that type must also be wrapped in IO. Basically, anything in the tree that touches this type is also IO, since it is in some way impacted by IO. The same "type wrapping" or "type pollution" happens if you access any side effect: DB access, network requests, API calls, etc. If you add an Integer
to an IO Integer
, you must get an IO Integer
back out of that.
Rust does something similar with Fn
traits: FnMut
, FnOnce
, etc. I'll leave researching those up to y'all.
My suggestion here is not to do exactly what Haskell does, as we are making a procedural/imperative language that is not fully functional, and it would be incompatible. However, we can do something similar.
// deterministic, pure, beautiful
fn my_func() -> u32 {
return 5u32;
}
// polluted and corrupt with the machinations of society (global state)
fn side_effect(&self) -> State<u32> {
return self.state.some_num; // not sure how state access will work yet, this is an approximation
}
// working with a `State` type. Note that the function return type is `State` simply because it accesses state, even though it doesn't return it. The compiler will have to enforce this.
fn adds_to_state(&self) -> State<()> {
let _ = self.side_effect();
return;
}
fn mutates_state(&mut self) -> MutState<u32> {
self.state.some_num = self.state.some_num + 1;
return self.state.some_num;
}
// They would be generic types so we could have operators and things work on them without any loss of ergonomics.
impl std::ops::Add for MutState<T> where T: std::ops::Add {
fn add(&self, other: &T) -> MutState<T> {
MutState(self.0 + other)
}
}
Proposal: Mimic Solidity
I don't like this idea as much, but for the sake of having options, we can also mimic Solidity and add more keywords to function definitions and yell at the programmer if they don't line up.
pure fn my_func(&self) -> u32 {
return 5u32;
}
view fn side_effect(&self) -> u32 {
return self.state.some_num;
}
// this to me is not as clear, and your types don't reflect where in the function you are accessing state.
// for example, we could call 10 functions, and you don't know which one is requiring this function to be a `view` fn without checking every single one.
view fn uses_side_effect(&self) -> u32 {
foo();
non_view_fn();
etc();
return self.side_effect();
}