I think deterministic programming is at the core of what makes large systems scalable and maintainable.
Why is this? Because determinism is the foundation of testability and reproducibility. The concept of determinism is stating that regardless of what environment we run in (an implicit input), we get the same output for the same explicit inputs. With testability we can ensure that our assumptions about the system can be encoded and automated. With reproducibility we can be sure that our function doesn't implicitly change depending on different environments.
A model for functions
Functions for many programming languages can be formalized as such:
f : X -> Env -> (Y, Env)
Where X is an arbitrary input tuple, Env is the environment that's implicitly passed into the function, and Y and Env is its output.
That Env in the output tuple is what the environment is transformed into.
Deterministic functions are different. They look like the following:
f : X -> Env* -> (Y, Env*)
Here Env* denotes an environment that is only "writable", but not "readable". This means for instance that we can write to an output stream, but cannot read from it. In other words: there is no information in the environment that we can use to make decisions.
Such functions do not depend on the input environment for their output values. One can argue that an output stream can be in a misconfigured state such that the apparently "deterministic" function errs, but in this model we simply let all operations that write to Env* be non-failing: for the function there is no information to extract from Env*.
Why don't all programs use deterministic functions if they make life easier?
The problem of verbosity
Explicitly passing parts of the environment down to the functions that need environment data is tedious. Imagine specifying that a piece of code should print what it is doing, then we'd need to pass "verbose" argument into each function:
my_function1(a, b, c, verbose); my_function2(d, e, verbose); ...
This requires that all functions that depend on functions requiring this flag also need this flag. It is simply not scalable. For every input decision in some function, all depending functions need to also add this flag. This is very impractical.
Solution: Reflective Programming
One way to solve this is to accept the flags in all these functions, but to have an environment object that fills in inputs that are considered "environment":
(env.fill_in(my_function1))(a, b, c); (env.fill_in(my_function2))(d, e);This too is tedious, but it can be automated.
env.fill_in_all( my_function1(a, b, c), my_function2(d, e), )
But this still means that we need to pass down these variables into dependencies. One way to deal with this is to segment function arguments into Envs and non-envs. That is, we decide based on practicality which inputs are to be passed explicitly and implicitly.
define my_function1(a, b, c, Env[verbose]) { env.fill_in(dependency_function)(a); ... }
The above code explicates the "environment" dependencies. They are no longer hidden from the programmer. In a larger codebase with many environment variables we might have:
define my_function1(a, b, c, Env[collection*]) { ... }
Where collection*
denotes a set of parameters this function takes. Linters/compilers can use this information to check for unused environment inputs. The main improvement here is that all inputs required for making decisions in the function are known from the function signature.
Finishing up
To make this practical we need to implicitly pass the subset of Env down to all callees and have code-editing tools that add a new dependency to all dependees. The implicit passing of environment variables must be overridable.
An ideal scenario
Here's how some Rust code could look like with this method:
fn main(env: Env) { print_state(123); } fn print_state(state: i32, env: Env[stdout]) { println!("state={}", state); } #[test] fn test_print_state(env: Env) { struct MyStdout(String); impl Write for MyStdout { fn write(&mut self, buf: &[u8]) -> std::io::Result{ self.0.extend(std::str::from_utf8(buf)); Ok(buf.len()) } } let test_env = env // Overwrite the stdout variable with custom data .override(|x| &mut x.stdout, MyStdout(String::new())) // Reduce environment to only the variables that print_state requires .reduce(print_state); print_state(123, test_env); assert_eq!("state=123", test_env.stdout.0); }