The power of OOP and abstraction
The beginning is known equation: IO a = State RealWorld a
1. This makes me think: what if RealWorld
can be inherited? It turns out I build IO
monad in Java like below:
static class Ability implements FileSystem, Console {} static class Ability2 implements Console {} static IO<One> step1() { return IO.<Ability>mk() .bind((w) -> { var content = w.readFile(Path.of("test.txt")); w.println("file content: " + content); return IO.pure(One.mk()); }) .with(new Ability2()) .bind((w) -> { w.println("hello world"); return IO.pure(One.mk()); }); } // invoke the IO monad step1().run(new Ability());
完美的用法:使用者可以任意更改 ability set 的 IO Monad pic.twitter.com/8xUpTLiV5w
— 悃悃 (@dannypsnl) April 30, 2023
1. But we are not in functional world
We have no do
notation or macro in Java, and that turns out is good, that made me rethink about how to use such idea realistic. First, we already have mutable in Java by default, we don't have to using State
monad at all, instead, we can just update the RealWorld
object in IO
class! Second, we don't actually need to combine the effect by a monad, we can just do it. Therefore, the correct question would be: how could we limit the computation?
2. Inherited computation
Hence, I get the solution: we only use inherited computation in our interface! For example, we have:
public interface Example extends FileSystem, Console { default void example1() { var content = readFile(Path.of("test.txt")); println("file content: " + content); } default void example2() { var userName = readInput(); println("user name is: " + userName); } }
The only rule is we don't invoke computation we cannot find locally! You might wondering why is interface, I will explain it in the next section. Except that, we just define our effect interfaces!
public interface Console { default void println(String msg) { System.out.println(msg); } default String readInput() { Scanner scanner = new Scanner(System.in); return scanner.nextLine(); } }
Above program explain how one program runs with fixed computation, but it has not say what if we want to invoke others implementation? For example, if A
will invoke methods of B
, what should we do?
3. Interface as computation set
It's straightforward: inheriting B
!
public interface A extends B, Console {}
The program will be able to invoke methods of B
, and this kind of usage is exactly why we must using interface
instead class
!
An important but might not clear point is, A
will also inherited computation from B
. For example, if B
has FileSystem
, then A
can invoke FileSystem
as well. This is a desired behavior, since if a function will involve computation somewhere, then the computation should pop up.
Another propety is, if we have two parallel inherited chain, they can have non-related effects at all. This is also important that we are freely combining these computation in the top (e.g. main function).
This eventually leads us to a very simple and helpful idea to manage IO effects.
4. Appendix
The IO
monad in Java
public class IO<A> { State<RealWorld, A> inner; <T extends RealWorld> IO(State<T, A> s) { inner = (State<RealWorld, A>) s; } public <T extends RealWorld> Pair<A, T> run(T s) { return (Pair<A, T>) this.inner.run(s); } public static <T extends RealWorld> IO<T> get() { return new IO<>(State.get()); } public static <T extends RealWorld> IO<One> put(T world) { return new IO<>(State.put(world)); } public static <A> IO<A> pure(A a) { return new IO<>(State.pure(a)); } public <B> IO<B> bind(Function<A, IO<B>> fact2) { return new IO<>(new State<>((world) -> { var tmp = this.run(world); return fact2.apply(tmp.fst).run(tmp.snd); })); } public static <T extends RealWorld> IO<T> mk() { return IO.get(); } public <T extends RealWorld> IO<T> with(T world) { return this.bind((_w) -> IO.put(world)).bind((_one) -> IO.get()); } }