I. Pure Functions
Pure functions are functions that have no side effects, with several benefits:
-
Referential transparency
-
Reason about your code
P.S. About referential transparency, see [Basic Syntax_Haskell Notes 1](/articles/基础语法-haskell 笔记 1/#articleHeader1)
Zero side effects is key, but some side effects are unavoidable and crucial, such as:
-
Output: Display to Console, send to printer, write to database, etc.
-
Input: Get user input from input devices, get information from network requests, etc.
So, how does functional programming that advocates pure functions handle these scenarios? There are 2 solutions:
-
Dependency injection
-
Effect Functor
II. Dependency Injection
We take any impurities in our code, and shove them into function parameters. Then we can treat them as some other function's responsibility.
In short, push out impure parts as parameters
For example:
// logSomething :: String -> String
function logSomething(something) {
const dt = new Date().toISOString();
console.log(`${dt}: ${something}`);
return something;
}
logSomething function has two impure factors, Date and console are secretly taken external state, so for the same input (something), doesn't necessarily output same result (log behavior and output content are both uncertain). So, to satisfy same input always corresponds to same output, adopt this deceptive approach:
// logSomething: Date -> (String -> *) -> String -> *
function logSomething(d, log, something) {
const dt = d.toISOString();
return log(`${dt}: ${something}`);
}
In this way, can achieve same input corresponds to same output:
const something = "Curiouser and curiouser!"
const d = new Date();
const log = console.log.bind(console);
logSomething(d, log, something);
Looks really stupid, keeping oneself pure seems meaningless. Actually, we did several things:
-
Strip out impure parts
-
Push them away, away from core code (got outside
logSomething) -
Made
logSomethingpure (behavior is predictable)
Meaning lies in controlling unpredictability:
-
Narrow scope: Move unpredictability into smaller functions (
log) -
Centralized management: If repeatedly narrow scope, and push unpredictability to edge (such as application entry), can let unpredictability stay away from core code, thereby guaranteeing core code behavior is predictable
So we end up with a thin shell of impure code that wraps around a well-tested, predictable core.
P.S. Doing this is also beneficial for testing, just need to swap out this impure thin shell to let core code run in simulated test environment, without needing to simulate full runtime environment
But this parameterized dependency injection approach is not perfect, its disadvantages are:
-
Long method signatures: For example
app(document, console, fetch, store, config, ga, (new Date()), Math.random) -
Long parameter passing chain: For example in React passing
propsfrom top-level component all the way down to some leaf component
Benefit of long method signatures is clearly marking which impure things upcoming calls depend on, but passing parameters layer by layer is indeed troublesome
III. Lazy Function
Another approach to control side effects is, protect parts that produce side effects (put into subway explosion-proof sphere), participate in computation with this protective shell, only open shell to get value when result is needed
For example:
// fZero :: () -> Number
function fZero() {
console.log('Launching nuclear missiles');
// Code to launch nuclear missiles goes here
return 0;
}
Obviously, fZero is not a pure function, exists huge side effects (will launch nuclear missiles). For safety, wrap this dangerous operation into explosion-proof sphere:
// returnZeroFunc :: () -> (() -> Number)
function returnZeroFunc() {
return fZero;
}
Next can freely operate this sphere without triggering nuclear missiles:
const zeroFunc = returnZeroFunc();
roll(zeroFunc);
knock(zeroFunc);
But returnZeroFunc is still not pure function (depends on external fZero), might as well bring dependency inside:
// returnZeroFunc :: () -> (() -> Number)
function returnZeroFunc() {
function fZero() {
console.log('Launching nuclear missiles');
// Code to launch nuclear missiles goes here
return 0;
}
return fZero;
}
Don't directly return result, but return a function that can return expected result (has bit of [thunk](/articles/从惰性 io 说起-haskell 笔记 6/#articleHeader3) meaning), and so on:
// fIncrement :: (() -> Number) -> (() -> Number)
function fIncrement(f) {
return () => f() + 1;
}
const fOne = fIncrement(fZero);
const fTwo = fIncrement(fOne);
const fThree = fIncrement(fTwo);
// And so on…
We defined some special values, any operations on these values (passing, calculation, etc.) are safe without side effects:
// fMultiply :: (() -> Number) -> (() -> Number) -> (() -> Number)
function fMultiply(a, b) {
return () => a() * b();
}
// fPow :: (() -> Number) -> (() -> Number) -> (() -> Number)
function fPow(a, b) {
return () => Math.pow(a(), b());
}
// fSqrt :: (() -> Number) -> (() -> Number)
function fSqrt(x) {
return () => Math.sqrt(x());
}
const fFour = fPow(fTwo, fTwo);
const fEight = fMultiply(fFour, fTwo);
const fTwentySeven = fPow(fThree, fThree);
const fNine = fSqrt(fTwentySeven);
// No console log or thermonuclear war. Jolly good show!
These operations are equivalent to formula transformation, only during final algebraic calculation will side effects actually occur. Like precipitating side effects out, while dependency injection approach lets side effects float up, both approaches can achieve separating side effects, controlling unpredictability purpose
But, since definition of values changed (from values to functions returning values), we have to redefine addition, subtraction, multiplication, division... entire set of arithmetic operations based on values, this is really stupid, is there a better way?
IV. Effect Functor
At this point, we mapped values to functions returning values, and mapped value operations to functions that can operate on these special values. Wait a minute, mapping, explosion-proof sphere, wrapping, operating wrapped things... thought of something?
Yes, it's [Functor](/articles/深入 typeclass-haskell 笔记 4/#articleHeader5):
-- Haskell
class Functor f where
fmap :: (a -> b) -> f a -> f b
fmap defined behavior is precisely mapping content (values) inside container, then put back into container
Isn't this exactly what lazy function approach urgently wants?
Try implementing with JS, first create a container type (Effect):
// Effect :: Function -> Effect
function Effect(f) {
return {
get: () => f
}
}
With container can perform boxing/unboxing operations:
// Method with side effects
function fZero() {
console.log('some side effects...');
return 0;
}
// Boxing, wrap fZero into Effect
const eZero = Effect(fZero);
// Unboxing, take fZero out from Effect
eZero.get();
-- Corresponding to in Haskell
-- Boxing
let justZero = Just (\x -> 0)
-- Unboxing
let (Just fZero) = justZero in fZero
Next implement fmap:
// fmap :: ((a -> b), Effect a) -> Effect b
function fmap(g, ef) {
let f = ef.get();
return Effect(x => g(f(x)));
}
// test
let eOne = fmap(x => x + 1, Effect(fZero));
eOne.get()(); // 1
Or more idiomatic (function signature consistent) curried version:
const curry = f => arg => f.length > 1 ? curry(f.bind(null, arg)) : f(arg);
// fmap :: (a -> b) -> Effect a -> Effect b
fmap = curry(fmap);
// test
let eOne = fmap(x => x + 1)(Effect(fZero));
eOne.get()(); // 1
get()() that makes Effect run looks a bit wordy, simplify a bit, simultaneously bring fmap in, make all this more fitting JS flavor:
// Effect :: Function -> Effect
function Effect(f) {
return {
get: () => f,
run: x => f(x),
map(g) {
return Effect(x => g(f(x)));
}
}
}
Try it out:
const increment = x => x + 1;
const double = x => x * 2;
const cube = x => x ** 3;
// (0 + 1) * 2 ^ 3
const calculations = Effect(fZero)
.map(increment)
.map(double)
.map(cube)
calculations.run(); // 8
This series of map operations are all without side effects, only at final run() will trigger fZero's side effects, this is precisely meaning of lazy function approach: let side effects precipitate like sand to bottom, guarantee upper water is pure and transparent
P.S. Effect implemented above is actually equivalent to function Functor, [mapping operation acting on functions is actually function composition](/articles/functor 与 applicative-haskell 笔记 7/#articleHeader3):
-- Haskell
instance Functor ((->) r) where
fmap = (.)
(.) :: (b -> c) -> (a -> b) -> a -> c
(.) f g = \x -> f (g x)
// That is
map(g) {
return Effect(x => g(f(x)));
}
So key point lies in function composition (compose):
// Special value
const fZero = x => 0;
// Ordinary function
const double = x => x + x;
// Cannot directly double fZero
// Introduce Functor fmap concept
const compose = (f, g) => x => g(f(x));
// Without changing double, implement double fZero
compose(fZero, double)();
// (0 + 1) * 2 ^ 3
// compose(compose(compose(fZero, increment), double), cube)();
V. Summary
Whether dependency injection or Effect Functor approach, principle for handling side effects is restrict unpredictability it brings within certain scope, let other parts maintain pure characteristics
If we think of an application mixed with side effects everywhere as a cup of water mixed with sand, difference between two approaches lies in approach to make water clear is different:
-
Dependency injection: Let sand float up to top layer, form a thin impure shell, keep water below pure
-
Effect Functor: Precipitate sand to cup bottom, let water above clarify and become transparent
Admittedly, side effects still exist, haven't been eliminated. But through similar approaches can let most code maintain pure characteristics, enjoy deterministic benefits pure functions bring (think less):
You can be confident that the only thing affecting their behaviour are the inputs passed to it. And this narrows down the number of things you need to consider. In other words, it allows you to think less.
No comments yet. Be the first to share your thoughts.