bakpakin / Fennel

Lua Lisp Language
https://fennel-lang.org
MIT License
2.48k stars 126 forks source link

Surprising `match` result on Sequential Table #445

Closed fosskers closed 1 year ago

fosskers commented 1 year ago

Hi! Thanks again for Fennel.

I ran into a surprising match result today. Given:

(match [1 2 3]
  [] "empty"
  [1] "something!"
  _ "else")

This results in:

"empty"

The underlying Lua is:

local _5_ = {1, 2, 3}
if (_G.type(_5_) == "table") then
  return "empty"
elseif ((_G.type(_5_) == "table") and ((_5_)[1] == 1)) then
  return "something!"
elseif true then
  local _ = _5_
  return "else"
else
  return nil
end

So we see here that in the first branch, only the type is checked and not the shape of the innards. So even though our table is larger than the given pattern, this match-branch succeeds. I found this result surprising relative to the match semantics of other languages, but is this by design? (I'd honestly expect the second branch to fail too, but we can see from the Lua that it too would pass if given the chance.)

Cheers!

andreyorst commented 1 year ago

Hi! Yes. this is by design, though it is not described anywhere. Should be added to the reference.

technomancy commented 1 year ago

It's mentioned in the reference:

Tables will never fail to match due to having too many elements - this means [] matches any table, not an empty table.

Maybe we could make it clearer tho; I don't know. Open to suggestions.

This is consistent with the way that argument checking works on lambda: we ensure that all the given locals have a value, but there are no checks to see whether too many args were passed in.

fosskers commented 1 year ago

Thanks guys, this is clearer now. This also brought case to my attention, so that's good.

there are no checks to see whether too many args were passed in.

And also too few, it looks like:

(match [1 2 3]
  [1 2 3 _ _] "something"
  [] "empty"
  _ "else")

Returns "something", but:

(match [1 2 3]
  [1 2 3 4 _] "something"
  [] "empty"
  _ "else")

Returns "empty". I'm not (as) surprised by the latter, but am certainly by the former. For the former we see some cute Lua:

if ((_G.type(_1_) == "table") and ((_1_)[1] == 1) and ((_1_)[2] == 2) and ((_1_)[3] == 3) and true and true) then

re: the and true and true. What's reasonable to expect in this case?

technomancy commented 1 year ago

The first one matches because _ means "always match no matter what"; this is a common idiom across most languages that have pattern matching. Similarly any symbol starting with a question mark is allowed to be nil. But any other symbol will prevent it from matching:

(match [1 2 3]
  [1 2 3 x _] "at-least-4"
  [] "maybe-empty"
  _ "else")
fosskers commented 1 year ago

Thanks! I'll keep these semantics in mind.

For completeness sake:

Haskell

-- outputs "else"
test = case [1,2,3] of
  1:2:3:_:_ -> "something"
  []        -> "empty"
  _         -> "else"

-- outputs "something"
test = case [1,2,3] of
  1:2:3:_ -> "something"
  []      -> "empty"
  _       -> "else"

Rust (notice the syntactic difference between "there is a slot here and I don't care what it is" vs "I don't care what comes after this")

// outputs "else"
fn test() -> &'static str {
    match vec![1, 2, 3].as_slice() {
        [1, 2, 3, _, _] => "something",
        [] => "empty",
        _ => "else",
    }
}

// outputs "something"
fn test() -> &'static str {
    match vec![1, 2, 3].as_slice() {
        [1, 2, 3, ..] => "something",
        [] => "empty",
        _ => "else",
    }
}

Emacs Lisp

;; outputs 'something
(pcase '(1 2 3)
  ((seq 1 2 3 _ _) 'something)
  ('() 'empty)
  (_ 'else))

;; outputs 'something
(pcase '(1 2 3 4 5 6)
  ((seq 1 2 3 _ _) 'something)
  ('() 'empty)
  (_ 'else))

;; outputs 'else
(pcase '(1 2 3)
  ('(1 2 3 _ _) 'something)
  ('() 'empty)
  (_ 'else))

Common Lisp

(ql:quickload :trivia)

;; outputs 'else
(trivia:match '(1 2 3)
  ((list 1 2 3 _ _) 'something)
  ('() 'empty)
  (_ 'else))

Elixir (similar to Rust, we see a syntactic difference between ignoring individual slots vs. the rest)

# outputs "else"
case [1, 2, 3] do
  [1, 2, 3, _, _] -> "something"
  [] -> "empty"
  _ -> "else"
end

# outputs "something"
case [1, 2, 3] do
  [1, 2, 3 | _] -> "something"
  [] -> "empty"
  _ -> "else"
end

So in general, it seems like pcase in Emacs Lisp resembles Fennel's behaviour, but other languages often have a way to distinguish between an ignored single slot and an ignored "rest".

technomancy commented 1 year ago

There's one thing all your languages listed have in common with each other but not with Fennel: in all of them, calling a function with more arguments than it is declared is an error. In Fennel, the additional arguments are ignored.

So Fennel's match behavior is consistent with its function calling behavior. The one that's inconsistent is Emacs Lisp.

fosskers commented 1 year ago

Roger, thanks. The take-home for me (and future readers) is thus:

So Fennel's match behavior is consistent with its function calling behavior.