Several mainstream languages have added better support for lambda expressions (including lighter syntax) in the last few years. This can let you define new forms of control structures, and reduce duplication in a different way than say extracting a common method.
Using lambda expressions for control structures is not a new idea; it was explored in the 1960s and 1970s in Lisp and other languages. (See References.)
A Running Example
Suppose you have some code like this:
make_a_call_across_the_network(with_some_parameters_maybe)
We can of course simulate that code:
System.out.println(message); try { sleep(3000); } catch (InterruptedException e) {} if (Math.random() < 0.8) throw new RuntimeException("it throws");
This code models what it’s like to work with network code: it’s slow, has side effects, sometimes throws exceptions, and sometimes seems random. It’s awkward.
This code will often work, but will occasionally fail. You’d like to give it a few chances to succeed, in case it just failed for a short-term problem. So, you wrap the code like this:
public void callInteresting(int retries) { RuntimeException lastException = null; for (int i = 0; i < retries; i++) { try { var message = "that's interesting: " + i; System.out.println(message); try { sleep(3000); } catch (InterruptedException e) { } if (Math.random() < 0.8) throw new RuntimeException("it throws"); return; } catch (RuntimeException e) { lastException = e; } } throw lastException; }
The callInteresting()
method takes an argument for the number of retries, and calls the flaky code up to that many times. If it ever succeeds, it returns. If it fails too many times, it throws the last exception it got. This is a basic approach; you can add exponential backoff [https://en.wikipedia.org/wiki/Exponential_backoff] or other complexity as you need to.
Unsatisfying Approaches
Whatever you end up doing, it probably applies to a lot of calls. And if you’re not careful, that can mean a lot of duplication.
One solution is to define a template or encourage cut-and-paste. This may be easy in the short term but creates problems in the long term, as the duplication hides problems, and repairs and upgrades are applied inconsistently.
We could take an object-based approach: define an interface or parent class for “retryable” code, and make every returnable object use the same method name. That forces an artificial similarity (using the same name for methods that might not otherwise be similar). If we have two methods on the same class that we want to retry, we have to split up the class.
Or, we might try to use generics, where our type may be less constrained, but we’re still calling a particular method on the objects.
The Lambda Approach
Another alternative is to shift from passing objects to passing methods.
Now, the idea of passing a method or function has been around for a long time. Lisp was built around it more than 60 years ago, and other languages have allowed simple cases. The idea of using lambda expressions (anonymous functions) for control structures has been around for 50+ years. What’s different now?
The big difference is that many current mainstream languages have lambda expressions, and most of them have simplified the syntax in the last few years. Anonymous functions don’t need a type or even a name.
Our Strategy: Convert to Lambda Expression
To make this transition, we have a strategy:
- Extract the inner code to a method [traditional Extract Method]
- Turn the call into a lambda definition and a call on the lambda
- Pass in the lambda as a new argument [Introduce Parameter]
Let’s see that on our code.
1. Extract Method
private void awkward(String message) { System.out.println(message); try { sleep(3000); } catch (InterruptedException e) { } if (Math.random() < 0.8) throw new RuntimeException("it throws"); } public void orig_callInteresting(int retries) { RuntimeException lastException = null; for (int i = 0; i < retries; i++) { try { var message = "that's interesting: " + i; awkward(message); return; } catch (RuntimeException e) { lastException = e; } } throw lastException; }
2. Turn the Call Into a Lambda Variable
Turn the call to “awkward” into a lambda variable and call it. In Java, we need a type for our call. If you check out java.util.function (see References), you’ll see there are a number of predefined types for lambdas, or you can make your own.
This line:
awkward(message);
becomes:
Consumer<String> fn = (String s) -> awkward(s); fn.accept(message);
where Consumer<String>
is defined to take one argument and have a void result.
3. Introduce Parameter
Introduce Parameter to move the lambda to the caller(s). (Using IntelliJ, I selected the lambda expression for Introduce Parameter, gave it the name “fn”, and deleted the redundant assignment.)
That results in this code:
public void callInteresting(int retries, Consumer<String> fn) { RuntimeException lastException = null; for (int i = 0; i < retries; i++) { try { var message = "that's interesting: " + i; fn.accept(message); return; } catch (RuntimeException e) { lastException = e; } } throw lastException; }
and this call site:
callInteresting(4, (String s) -> awkward(s));
What’s nice about this code is that now the call site is where the function body is defined. (If you want to, you can inline that method or simplify the reference to awkward.)
We can call callInteresting
with other lambda expressions, so long as they’re String → void.
Limitations
Depending on the language, lambda expressions may not be fully realized. For example, Java has limitations around using final or final-like variables, and it makes use of the single-method interfaces such as those in java.util.function. Other languages have their own challenges.
Swift Version with Lambda Expression
For contrast, here’s the Swift version of the above code. Swift handles the lambda expression more consistently. It also has a nice syntax convention where lambdas that are last arguments can be moved to outside the parentheses – letting it look even more like a user-defined control structure.
func callInteresting( retries: Int, fn: (String) throws -> Void) throws { var lastError: Error? = nil for i in 0..<retries { do { let message = "that's interesting: \(i)" try fn(message) return } catch { lastError = error } } throw lastError! } func helper(_ message: String) throws { print(message); sleep(3) if (Float.random(in: 0..<1) < 0.4) { throw MyError.myError("it throws") } } try! callInteresting(retries: 4) { try helper($0) } try! callInteresting(retries: 4, fn: helper)
Conclusion
“New” lambda expressions in mainstream languages make for a convenient abstraction that lets us create custom control structures. We worked through an example that retries code, but there are other possibilities. However, languages may have limitations that prevent using this approach in every situation.
References
“Package java.utiil.function”, Oracle. Retrieved 2021-10-10.
“Lambda, the Ultimate Imperative”, AI Memo 533 by Guy Lewis Steele Jr and Gerald Jay Sussman. July, 1976.