Welcome! Please see the About page for a little more info on how this works.

+10 votes
in Clojure by
Description of issue:

Suppose I want to create multiple bindings with let and then execute a body. I can do that easily like so:

(let [a 1
      b (inc a)
      c (* b b)]
  [a b c])


But, if I want to do the same type of thing with if-let, I can only do so by nesting them because if-let only accepts one binding at a time.

(if-let [a 1]
  (if-let [b (inc a)]
    (if-let [c (* b b)]
      [a b c]
      "error")
    "error")
  "error")


This is very inelegant because:
1) It is not as simple to read as it would be if all of the bindings were next to each other on the same indentation
2) The else clause it duplicated multiple times.
3) The else clause is evaluated in a different context depending which binding failed. What if a was already bound to something? If the if-let shadows a, and b does not get bound, the else clause would be executed with a different value bound to a than if a was not shadowed in the first if-let. (see code below for example)

I want to be able to write this instead:

(if-let [a 1
         b (inc a)
         c (* b b)]
  [a b c]
  "error")
=> [1 2 4]

(let [a :original]
  (if-let [a :shadowed
           b false]
          a a))
=> :original


I also want to be able to do a similar thing with when-let, if-some, and when-some.

*Proposed:*

I re-wrote those macros to be able to handle multiple bindings. If supplied with just one binding, their behavior remains identical. If supplied with multiple bindings, they should only execute the body if every binding passed. In the case of some bindings passing and some failing in if-let or if-some, none of the bindings should leak into the else clause.

*Patches:*

- clojure-core v2 8-3-2017.patch - Clojure patch with macro updates. For if-let and if-some, I had to add a bit of extra logic in order to prevent them from leaking bindings to the else clause in the case of some bindings passing and some failing. It also includes a few extra tests around each macro.
- core.specs.alpha.patch - core.specs.alpha patch with equivalent updates to core specs
by
I would like to improve Issue 2213 as follows. Do you think, this makes sense?

The new description would be:

When developing Input-Process-Output tools I am often in the situation, that I have a sequence of activities (e.g. opening a file, spec checking a file, calling external tools, creating files), which each could fail in certain ways. Clojure does not have a good way to control such a sequence of activities in a way that all sorts of things, which could go wrong, can be handled comfortably. In imperative programming languages concepts like early exit or bailout are used. These are not not very functional. Unfortunately practical problems are often like this.

The issue is a bit more general and a solution would be nice not only for those who develop such sort of tools. So I can give a simple example.

(let [config-file (get-file-from-network xy)
      config-file (parse-edn config-file)
      syntax-ok?   (spec-check config-file)]
  (do-something-with config-file))

As all these steps can fail, we could instead implement it like this.

(if-let [config-file (get-file-from-network xy)]
  (if-let [config-file (parse-edn config-file)]
    (if (spec-check config-file)
      (do-something-with config-file))))

Now the code has suffered already a lot. The sequential nature of the problem is lost. And it is not even doing what we need. We have to report to the user what went wrong.

(if-let [config-file (get-file-from-network xy)]
  (if-let [config-file (parse-edn config-file)]
    (if (spec-check config-file)
      (do-something-with config-file)
      :file-has-syntax-errors)
    :file-not-valid-edn)
  :file-cannot be openned)

But this is still not doing what we need. We have to provide more information about each failure. If a file cannot be openned, is it because the file does not exist or we do not have read permissions? And what did the spec check tell us? So we cannot just use nil as a universal failure. We must deliver more information when failing. And we cannot use if-let, either. So we are not even done.

This problem report asks for a better solution for this conceptual challenge.

This example depicts a conflict that should not happen when using an appropriate programming language: First we are able to quickly make a prototype. But in the end the final code has not much in common with the first code any more. The final design is not driven by the problem domain, but by the technical need for an error handling. The need for a bailout mechanism in this example imposes too many huge code changes.

The principal problem with all these solutions is that they coerce a sequence of actions into something which is not a sequence. If your problem is a sequence, then the implementation should also be a sequence.
Idea: Exceptions

As an alternative implementation I went with Exceptions. But it feels wrong, because I am talking about things, that are expected to fail. I cannot just use the Exceptions that are comming out of Java for example when opening a file for read. I need detailed information, what went wrong. If you are a command line tool, it is expected from you, that you provide good error messages. I ended up catching and rethrowing exceptions everywhere in the program.
Idea: Like for/doseq

For me I created a small macro, that extends let. Then I use this syntax:

(let [[config-file error] (get-file-from-network xy)
      :escape     error
      config-file (parse-edn config-file)
      syntax-ok?   (spec-check config-file)]
  (do-something-with config-file))

This often allows a surprisingly intuitive code. For example here:

(let [[config-file error-code] (get-file-from-network xy)
      :escape     (if (= error-code :does-not-exist)
                    :file-xy-does-not-exist)
      :escape     (if (= error-code :io-error)
                    :file-xy-cannot-be-read)
      [config-file error-code] (parse-edn config-file)
      :escape     (if error-code :file-xy-not-legal-edn)
      syntax-ok?   (spec-check config-file)]
  (do-something-with config-file))

This is inspired by the for/doseq. But I don't claim that this is a good solution for Clojure (although I think so). But I think, the let macro is a good candidate when improving how Clojure can be used for such imperative things.
Idea: Using if-let

I also investigated the solution, that was originally proposed by this issue.

(if-let [config-file (get-file-from-network xy)
         config-file (parse-edn config-file)
         config-file-ok? (spec-check config-file)]
  (do-something-with config-file)
  :error)

This problem with this solution is that none of the bindings are available in the else part. That makes it very hard to react accordingly. This is a nice and small new language feature. But I don't think, that in practice it would be of help so often. The example from the original issue was very artificial with mathematical operations that cannot fail. The first draft of this issue with that artificial example does not actally describe a real problem. That is why this issue has completelly been rewritten.

Or course we could still bind everything, which was not failed. But I don't think, we would want that. It would have very interesting consequences to the compiling model.
Idea: Threading macros

As this issue is a about a sequence of activities, a new type of threading macro (e.g. let->) could also be an intuitive solution. But I have not tried anything like this.
Final Words

The principle challenge is, that all these solutions establish a sort of alternative control flow. It is a hard challenge to do this right, especially in a functional language. Like exceptions, that immediatelly exit and magically jump somewhere else. Clojure as a practically language could address this challenge.

5 Answers

0 votes
by

Comment made by: alexmiller

What's the relationship of this to CLJ-2007?

0 votes
by

Comment made by: justinspedding

I posted my solutions to that ticket as code in a comment. Then, you posted about the correct format of tickets and linked to the ticket creation guidelines. I figured that meant that you wanted a ticket to be made that followed the conventions.

Also, this ticket is about modifying the existing macros. CLJ-2007 was about creating 2 new macros: if-let* and when-let**.

0 votes
by

Comment made by: gshayban

It is worth looking at what the JVM is intending on doing with test-and-destructure intrinsics. Brian Goetz covers this in a recent talk on pattern matching (link: 1)

(link: 1) https://www.youtube.com/watch?v=n3_8YcYKScw

0 votes
by

Comment made by: justinspedding

An updated patch that simplifies the generated code when 0 bindings are given to if-let and if-some

0 votes
by
Reference: https://clojure.atlassian.net/browse/CLJ-2213 (reported by justinspedding)
...