Hi fellow Kotliners! My first post, glad to be her...
# arrow
m
Hi fellow Kotliners! My first post, glad to be here! I'm trying to learn some Functional Programming concepts, and have a question about the traditional "Writer" Monad. Let's say that we have a long chain of operations representing a "use case" of our domain. If we want to instrument all of the operations with logs, a traditional Writer Monad could be used to collect the log we want to associate with each operation and concatenate them as we go along the chain of operations. In the end, the caller of this use case can extract the logs and publish them to a logging framework. However, if we should follow the philosophy of letting "exceptions be exceptional" (which is...sort of...communicated as Kotlin-idiomatic by the Kotlin team), we want to keep this chain free from try-catch blocks and only handle anticipated "domain failures". Let's say that we encounter a totally unexpected exception halfway through this chain of operations. Since we haven't actually reported any of the logs collected up until that point to any logging framework, they would be lost. That doesn't sound optimal to me. If we bend a little on that exception philosophy, I guess we could catch exceptions in the Writer and wrap them in a new one that also has the logs collected up until that point. Another idea I had that feels somewhat nicer, is to have the caller instantiate a collection of some sort before calling the use case, and passing a function for adding logs to this collection. That way we can always grab that collection out in the caller when we are handling the unexpected exception and publish what we managed to collect. I have only just started learning about the "Reader" Monad, but passing in a "log collecting function" feels more akin to a Reader than a Writer. However I would still think it looks nicer to preserve the way of just "giving a string" to the Writer as in the traditional case of concatenating logs, only that the Writer has a "locked in" log-collecting function that the caller set in the beginning, which gets called behind the scenes when the business operations in the chain just "hands their log string" to the Writer. ๐Ÿ™‚ So my question is: Do any of you take extra steps to get around this problem of losing logs on un-caught exceptions? Or maybe this is not considered enough of a problem to warrant this extra headache? ๐Ÿ˜‰
y
The concept you're looking for is different nestings of monads. An
Either<E, Writer<X, A>>
keeps logs only upon success. A
Writer<X, Either<E, A>>
keeps logs in both cases. Passing an
accumulate
function along to add the errors is definitely the Kotlin idiomatic approach to do this. This is analogous to how Raise passes a
raise
function around. This technique is more broadly called "capability passing style", where you pass along a way of doing some "effect". This is exactly what Reader is. In fact, this technique basically allows any monad to be viewed as Reader and Cont (if interested, look into the concept of delimited continuations and algebraic effects). Let's get back to Kotlin:
Copy code
fun interface Write<S> {
  fun write(s: S)
}
inline fun <S, R> writer(block: Write<S>.() -> R): Pair<List<S>, R> {
  val result: R
  return buildList {
    result = block({ add(it) })
  } to result
}
// Usage
writer {
  either {
    write("foo")
    raise(42)
  }
} // == listOf("foo") to Left(42)
either {
  writer {
    write("foo")
    raise(42)
  }
} // == Left(42)
The nice thing is that you can write a function that uses
Raise
and
Writer
without caring about what order they're nested in. Then, the caller decides the nesting based on what they want, simply by changing the nesting of the "handlers" (here
either
and
writer
) I'll leave it as an exercise to you to change this to work with monoids (hint:
fold
).
If you've ever heard about Monad Transformers, this is a similar idea, but it's better in many regards, including that the order of nesting is only decided by the caller, and that instances can be named (e.g. you can take in 2 writers, and maybe one has less important logs, and the caller might decide to not preserve those upon failure or something). Also, context parameters help a lot here since your "capabilities" can be passed in implicitly to functions.
(If you want to see the limits of what's possible with this technique, see also GitHub.com/kyay10/Kontinuity, where I've taken this technique to the extreme, defining monad comprehensions, and doing more complex effect ordering with unusual effects like non-determinism)
โค๏ธ 1
K 2
arrow intensifies 1
๐Ÿฆœ 1
Oooo, I think I slightly misread your question, although my answer still stands pretty well. What you'll wanna do is define a handler for Write that is aware of your logging framework, and it'll will simply log the strings when an exception happens. Having this in the handler is nice, because e.g. if an error is caught because it was intentional, and the catching happens inside the block passed to the handler, the handler is none-the-wiser, and the logs are preserved. Apologies for the wall-of-text. Feel free to ask any clarifying or further questions
m
Thank you so much Youssef for taking the time to explain!! Very valuable! (Wall of texts are very welcome.) On a side note, I forgot to preface my question with mentioning that I've only used Arrow a tiny bit, and that was a year ago - before I dove into the actual FP concepts (I only used
Either
). So at the moment, I'm trying to grasp the concepts without Arrow to learn their inner workings before returning to Arrow and seeing how it can help me save some boilerplate. ๐Ÿ˜‰ And at the same time, I get to practice Kotlin a bit more - I've only written Kotlin for about 1,5 years. I will take some time to digest your answer and your examples, but a few questions popped up that I can ask right away! You mention the approach of using
Writer<X, Either<E, A>>
to log "in both cases". Does that mean I would use
Either
to carry unexpected Exceptions as well as "domain failures"? Last year I dove into error handling in Kotlin and tried my best to interpret what the recommended Kotlin-idiomatic way is. After learning about
Result
and its shortcomings, I formed a picture where I would use
Either
-like structures for domain failures and top-level try-catch blocks (or `CoroutineExceptionHandler`s) for unexpected Exceptions. A related question: A "handler for Write that is aware of your logging framework, and it'll will simply log the strings when an exception happens" sounded interesting! One aspect my brain is still getting used to is imagining "where" things get called. (I'm still kind of new to coding, graduated 2022 ๐Ÿ˜‰ ) My "exercise project" is a system at work where I'm trying to rewrite the "domain" subproject in an FP style, and my mental model so far has been that no IO calls should be made "in" the domain, so to speak. The Writer with a handler that calls my logging framework, would that include a try-catch that invokes the handler and then re-throws the exception? Would that mean that my mental model gets broken as an IO call is made "in" the domain? Or is this one of those things where I need to shift my thinking to "all of the operations and calls defined in the use case in the domain are not made there, they are simply an instruction on how things should be done, and it is the caller that makes it all happen when it invokes the use case, so that IO logging call is not made in the domain - it is just defined there"? Sorry for the very philosophical question!! ๐Ÿ˜‰ ...oh, reading your suggestion one more time after writing all of that made me think that you maybe meant that the caller could pass in an "implementation" of a logging function (containing the actual call to the logging framework) to the Writer that should be invoked on catching-and-re-throwing exceptions - and then things would still be IO-free in the domain? ๐Ÿ˜„
y
I did misinterpret your question initially, so apologies for the confusing peripheral topics. I will elaborate further in a bit, but the basic idea is that everything in Kotlin basically takes place in
Result<R>
, where
Result<R> == Either<Throwable, R>
. So back to your question. You'll lose logs if your program is ultimately
Result<Writer<S, R>>
, but you won't if your program is
Writer<S, Result<R>>
. Hence you need to have your
Write
handler somewhere near the root of the program. What you suggest in your last paragraph is absolutely right: you can pass in your logging implementation still, and basically have an intermediate handler that keeps local logs, but pushes them upstream when an exception happens.
๐Ÿ‘ 1
m
Oh, so the recommendation is to use an
Either
-style Monad even for Exceptions? So you catch all
Throwable
(that would include
Error
, so perhaps just
Exception
?) right where they arise and wrap them in an
Either
? Should "domain failures" be right-side then, like
Either<Throwable, DomainResult>
?
Did you have some more tips and tricks @Youssef Shoaib [MOD]? ๐Ÿ˜‰