Record pattern and side effects

Brian Goetz brian.goetz at oracle.com
Sun Apr 17 14:58:26 UTC 2022


Yes, this is something we have to get “on the record”.  

Record patterns are a special case of deconstruction patterns; in general, we will invoke the deconstructor (which is some sort of imperative code) as part of the match, which may have side-effects or throw exceptions.  With records, we go right to the accessors, but its the same game, so I’ll just say “invoke the deconstructor” to describe both.  

While we can do what we can to discourage side-effects in deconstructors, they will happen.  This raises all sorts of questions about what flexibility the compiler has.  

Q: if we have 

    case Foo(Bar(String s)):
    case Foo(Bar(Integer i)):

must we call the Foo and Bar deconstructors once, twice, or “dealer’s choice”?  (I know you like the trick of factoring a common head, and this is a good trick, but it doesn’t answer the general question.)  

Q: To illustrate the limitations of the “common head” trick, if we have 

    case Foo(P, Bar(String s)):
    case Foo(Q, Bar(String s)):

can we factor a common “tail”, where we invoke Foo and Bar just once, and then use P and Q against the first binding?  

Q: What about reordering?  If we have disjoint patterns, can we reorder:

    case Foo(Bar x): 
    case TypeDisjointWithFoo t: 
    case Foo(Baz x): 

into 

    case Foo(Bar x): 
    case Foo(Baz x): 
    case TypeDisjointWithFoo t: 

and then fold the head, so we only invoke the Foo dtor once?

Most of the papers about efficient pattern dispatch are relatively little help on this front, because the come with the assumption of purity / side-effect-freedom.  But it seems obvious that if we were trying to optimize dispatch, our cost model would be something like arithmetic op << type test << dtor invocation, and so we’d want to optimize for minimizing dtor invocations where we can.  

We’ve already asked one of the questions on side effects (though not sure we agreed on the answer): what if the dtor throws?  The working story is that the exception is wrapped in a MatchException.  (I know you don’t like this, but let’s not rehash the same arguments.)  

But, exceptions are easier than general side effects because you can throw at most one exception before we bail out; what if your accessor increments some global state?  Do we specify a strict order of execution?  

You are appealing to a left-to-right constraint; this is a reasonable thing to consider, but surely not the only path.  But I think its a lower-order bit; the higher-order bit is whether we are allowed to, or required to, fold multiple dtor invocations into one, and similarly whether we are allowed to reorder disjoint cases.  

One consistent rule is that we are not allowed to reorder or optimize anything, and do everything strictly left-to-right, top-to-bottom.  That would surely be a credible answer, and arguably the answer that builds on how the language works today.  But I don’t like it so much, because it means we give up a lot of optimization ability for something that should never happen.  (This relates to a more general question (again, not here) of whether a dtor / declared pattern is more like a method (as in Scala, returning Option[Tuple]) or “something else”.  The more like a method we tell people it is, the more pattern evaluation will feel like method invocation, and the more constrained we are to do things strictly top-to-bottom, left-to-right.)  

Alternately, we could let the language have freedom to “cache” the result of partial matches, where if we learned, in a previous case, that x matches Foo(STUFF), we can reuse that judgment.  And we can go further, to require the language to cache in some cases and not in others (I know this is your preferred answer.)  



> On Apr 17, 2022, at 5:48 AM, Remi Forax <forax at univ-mlv.fr> wrote:
> 
> This is something i think we have no discussed, with a record pattern, the switch has to call the record accessors, and those can do side effects,
> revealing the order of the calls to the accessors.
> 
> So by example, with a code like this
> 
>  record Foo(Object o1, Object o2) {
>    public Object o2() {
>      throw new AssertionError();
>    }
>  }
> 
>  void int m(Foo foo) {
>    return switch(foo) {
>      case Foo(String s, Object o2) -> 1
>      case Foo foo -> 2
>    };
>  }
> 
>  m(new Foo(3, 4));   // throw AssertionError ?
> 
> Do the call throw an AssertionError ?
> I believe the answer is no, because 3 is not a String, so Foo::o2() is not called.
> 
> Rémi



More information about the amber-spec-experts mailing list