Sunday, 9 February 2020

egg Try Operator

I had a bit of an epiphany earlier in the year. Not one of "Archimedean" proportions (no bathwater was at risk), but I seem to remember audibly muttering "Ooh" at the time. It concerned egg guarded assignment statements.

In many programs, it's common to attempt a data access that you know will fail quite often. For example, looking up an entry in a cache. Programming languages and their libraries provide a number of patterns for doing this:

  // C++
  auto found = cache.find(key);
  if (found != cache.end()) {
    CacheHit(*found);
  } else {
    CacheMiss();
 }

or

  // C#
  if (cache.TryGet(key, out var found)) {
    CacheHit(found);
  } else {
    CacheMiss();
  }

or

  // JavaScript
  if (key in cache) {
    CacheHit(cache[key]);
  } else {
    CacheMiss();
  }

or

  // Java
  if (cache.containsKey(key)) {
    CacheHit(cache.get(key));
  } else {
    CacheMiss();
  }

The C# paradigm (a.k.a. "try-get") is arguably the most elegant (it's atomic and concise), but it leads to a bloated interface for the containers:

  // C#
  interface ICache<K,V> : ...
  {
    bool TryGet(K key, out V value);
    V Get(K key);
    ...
  }

In, egg these interface members look like the following functions:

  // egg
  type<K,V> bool TryGet(K key, V* value);
  type<K,V> V Get(K key);

Both these members perform essentially the same task, but the "TryGet()" method will return "false" if the key doesn't exist, whereas "Get()" will throw an exception.

If we don't supply the "TryGet()" member, we need to use the following paradigm to emulate it:

  // egg
  bool found;
  K value;
  try {
    value = cache.Get(key);
    found = true;
  } catch (Exception e) {
    found = false;
  }

Whereas if we only supply "TryGet()", we need to use the following to emulate "Get()":

  // egg
  K value;
  if (!cache.TryGet(key, &value)) {
    throw ExceptionKeyNotFound();
  }

Like C# 7 pattern matching, the egg language has the concept of guarded assignments:

  if (type variable = expression) {
    assignment-successful
  } else {
    assignment-failed
  }

But this doesn't handle the case where the evaluation of "expression" raises an exception: you'd still need to wrap the whole thing up in a "try-catch" block.

My original idea was to allow functions to return a "void" value which can never be successfully assigned to a variable. You could then use the following pattern:

  // egg
  type<K,V> V|void Fetch(K key);
  ...
  if (var value = cache.Fetch(key)) {
    CacheHit(value);
  } else {
    CacheMiss();
  }

This is good for guarded assignments, but there's no informative exception payload for standard assignments or other evaluations. The following:

  // egg
  var value = cache.Fetch(key);

will just report an unhelpful "unable to assign 'void' value" exception at run-time.

My almost-"Eureka!" moment came when I considered adding a "try" operator to the language:

  type variable = try expression

The new operator is prefix unary and attempts to evaluate its operand at run-time. If it succeeds, the result is the value of the expression. If an exception is raised during the evaluation of the expression, the result of the operator is "void".

In general, this operator only makes sense inside a guarded assignment:

  // egg
  type<K,V> V Get(K key);
  ...
  if (var value = try cache.Get(key)) {
    CacheHit(value);
  } else {
    CacheMiss();
  }

or, possibly, in an "if-try" condition:

  // egg
  if (try PerformSomething()) {
    Success();
  } else {
    Failure();
  }

In the latter case, the "try" operator takes an operand that evaluates to "void" and returns "false" or "true" depending on whether an exception is thrown. There's not a great benefit of this form beyond the usual standard "try-catch" syntax, and it hides the cause of the exception, so I'm less inclined to support it.

I haven't decided on the precise syntax for "guarded try assignments" (or indeed a better name!) but here are a few options:

Option 1 (currently preferred)

Place the "try" keyword immediately after the assignment operator:

  if (var value = try cache.Get(key)) {
    CacheHit(value);
  } else {
    CacheMiss();
  }

Option 2

Place the "try" keyword immediately before the variable type:

  if (try var value = cache.Get(key)) {
    CacheHit(value);
  } else {
    CacheMiss();
  }

Option 3

Place the "try" keyword immediately after the "if" keyword:

  if try (var value = cache.Get(key)) {
    CacheHit(value);
  } else {
    CacheMiss();
  }

Option 4

Replace the "if" keyword with "try":

  try (var value = cache.Get(key)) {
    CacheHit(value);
  } else {
    CacheMiss();
  }

Option 5

Replace the "if" keyword with "try" and add an optional "catch" clause:

  try (var value = cache.Get(key)) {
    CacheHit(value);
  } else {
    AssignmentFailed();
  } catch (Exception e) {
    ExceptionRaised();
  }

Option 5 shows promise because you have access to the precise exception that was raised in the evaluation of the expression, but it's not immediately obvious which failure clause gets executed under all circumstances.

As I said, it wasn't a full-blown "Eureka!" moment, but it looks promising. It is a deviation from the standard curly-brace syntax; but then, so is guarded assignment.

No comments:

Post a comment