radicle-alpha icon indicating copy to clipboard operation
radicle-alpha copied to clipboard

Make it possible to `match` an optional key in dicts with a fallback value.

Open jameshaydon opened this issue 6 years ago • 9 comments

jameshaydon avatar Dec 11 '18 15:12 jameshaydon

@jameshaydon I'm interested in working on this. Should the fallback value be an optional last argument: (lookup :foo { :bar #t } #f) ;; ==> #f?

maxhallinan avatar Apr 03 '19 14:04 maxhallinan

Hi @maxhallinan

No this poorly worded issues was actually about pattern-matching. The functionality you describe is currently implemented in lookup-default: https://github.com/radicle-dev/radicle/blob/master/rad/prelude/dict.rad#L61 although it might be better to make lookup-maybe a prim-fn since at the moment lookup-default and lookup-default perform 2 lookups.

Anyways the current issue was about matching keys in dicts. For example if we look at the pattern {:a 'a :b 'b :c 'c} then it'll only match dicts that have all the keys :a, :b and :c. But it might be the case that you also want to match dicts that only contain :a and :b, and in that case bind c to some default value.

I'm not sure how the default value should be specified in the pattern. Clojure's destructuring syntax is somewhat different and uses an :or key to specify a map of default values.

jameshaydon avatar Apr 03 '19 15:04 jameshaydon

I'm not sure how I got confused here - I think your intention is obvious from the title :)

I see two approaches to destructuring with a default value. The first approach is to set a default for the structure itself. Clojure takes that approach.

(def foo { :a "A" })
(let [{a :a, :or { b "B" }} foo] ...)

The second approach is to set defaults at the assignment level. Common Lisp and JavaScript take that approach.

Common Lisp

(destructuring-bind (a &optional (b "B")) '("A") ...)

JavaScript:

const foo = { a: "A" };
const { a = "A", b = "B" } = foo;

A reasonable semantics for the first approach might be something like mappend, where a nil value is treated as the identity value of that type. In other words, the given dict and the default dict are merged and the destructuring is applied to the result. That means that the entire structure can be treated as optional, not just values within the structure. I'm not sure if Clojure's :or works that way.

The second approach seems more readable. Clojure's :or requires repetition of brackets and field names. The result is relatively verbose. Default values at the assignment level reduce syntax noise. The second approach also makes it easier to determine what the default for each assignment is. Looking for correspondences between the :or map and the destructuring pattern convey this information in a less direct way. Common Lisp's &optional keyword followed by a tuple is also a bit verbose. A better syntax might be the ?= symbol:

(match { :a "A" } { :a :b ?= "B" } :b) 
;; "B"

Let me know what you think.

maxhallinan avatar Apr 06 '19 11:04 maxhallinan

A reasonable semantics for the first approach might be something like mappend, where a nil value is treated as the identity value of that type. In other words, the given dict and the default dict are merged and the destructuring is applied to the result. That means that the entire structure can be treated as optional, not just values within the structure. I'm not sure if Clojure's :or works that way.

That is indeed how Clojure works, indeed the destructuring of a map always works even if the keys are not present (unlike our pattern matching), and just assigned the value to nil.

However we are trying to avoid having a nil value.

The problem with { :a :b ?= "B" } is that it will parse as a rather bizarre dict (with two keys, :a and ?=).

What is proposed in the title of this issue is to allow for binding a default value to a variable in the case that the value is not present. But "variables" are just one sort of pattern. So in fact the more general proposal is that: Pass a default value to a sub-pattern if the value is not present.

The way we are represent optional values in Radicle is with [:just 42] or :nothing values. It would be easy to make a pattern /maybe that is used as follows:

(match [:just 77]
       (/maybe 42 'x) x)

So the pattern (/maybe 42 subpat) will, for [:just foo] pass foo to the sub-pattern subpat, but for :nothing it will pass 42 instead. In the example the sub-pattern is just 'x and so this has the effect of binding x to 77 for [:just 77], or to 42 if :nothing.

Now if we made the dict pattern take optional values instead of values, we could be much more precise. The pattern:

{:a [:just 1]
 :b :nothing
 :c (/maybe 42 'x)
}

will only match dicts that:

  • contain the association of :a to 1.
  • does not contain the key :b,
  • might contain the key :c, if so bind the value to x, else bind 42 to x.

However it does make things much more verbose. We could maybe keep the current pattern for dicts, but have this more elaborate option under a named pattern, e.g.:

(/associations
   :a [:just 1]
   :b :nothing
   :c (/maybe 42 'x))

(yay varargs!)

The advantage of this is that it's also a completely non-breaking change.

jameshaydon avatar Apr 11 '19 10:04 jameshaydon

Not directly a response, but the :nothing syntax for "does not contain" makes me think: how do we state "a dict that contains only these keys, and nothing else"? It seems like it'd help make validation easier.

The most obvious solution - something like:

(match {:a 1 :b 2}
  (/keys-&rest {:a 'a} {}) "extra keys"
  ...

Makes it feel like we need pattern matching to not only supply bindings, but also an extra thing - unmatched values.

jkarni avatar Apr 11 '19 10:04 jkarni

@jkarni Yes the fact that list/vector binding was "total" and the dict binding "partial" was something that irked my from the start. I still think partial-matching will be the most used form though (and so this is what the dict-syntax should be tied to).

So the pattern keys-&rest takes two sub-patterns specified and rest. First it checks that the value matches the specified pattern. If it does, then it calls keys on specified and then deletes those keys from the value to produce the rest of the dict, and it asks that this dicts matches the rest pattern.

The problem is that keys only works for the normal sort of dict pattern, not the /associations one.

jameshaydon avatar Apr 11 '19 10:04 jameshaydon

The problem with { :a :b ?= "B" } is that it will parse as a rather bizarre dict (with two keys, :a and ?=).

You're right - I overlooked that this is not only destructuring but pattern matching too.

Most of my experience is with ML-style pattern matching. I like it because I can explicitly and unambiguously address one variant at a time. I don't have to think much about which value matches the pattern, since the pattern closely resembles the value. Of course, patterns can be more or less specific, e.g. Foo a vs Foo 1. But the shape of the pattern describes the shape of the value.

It now occurs to me that patterns with optional components make it less clear which values match the pattern. Even something trivial like your example requires a new level of mental state for me to comprehend. As I look at the pattern, I wonder what it means when a dict definitely has field :a, definitely does not have field :b, and might have field :c. My impulse is to step back and represent the variants more explicitly. In this case, that might mean tagging the dict with a type.

Pattern matching (it seems to me) is meant to emphasize differences between values so that those differences can be handled precisely. Optional patterns de-emphasize those differences and I would guess that the handling becomes less precise. On the other hand, destructuring with default values means unifying all the variants into one. The effect is that the differences (presence/absence of fields) are erased. Defaults are used to conform all values to the same pattern. Understood this way, it seems possible that combining these features would be working at cross purposes.

This might just be the usual discomfort in the face of unfamiliar syntax.

maxhallinan avatar Apr 13 '19 13:04 maxhallinan

@maxhallinan Indeed pattern matching in untyped languages has quite a different flavour, since values aren't neatly partitioned into types.

I still think that /associations as in https://github.com/radicle-dev/radicle/issues/323#issuecomment-482052170 would be quite useful, let me know if you want to implement it.

jameshaydon avatar Apr 25 '19 07:04 jameshaydon

@jameshaydon sounds good! I'll implement this as described above.

maxhallinan avatar Apr 27 '19 11:04 maxhallinan