Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Include subtypes in case patterns #1715

Open
RustanLeino opened this issue Jan 14, 2022 · 14 comments
Open

Include subtypes in case patterns #1715

RustanLeino opened this issue Jan 14, 2022 · 14 comments
Labels
kind: enhancement Enhancements, feature requests, etc. These are NOT bugs, but ways we can improve Dafny part: language definition Relating to the Dafny language definition itself

Comments

@RustanLeino
Copy link
Collaborator

RustanLeino commented Jan 14, 2022

This is a proposal to extend how Dafny handles explicitly typed bound variables in case patterns. In particular, the proposal is to make such types part of the pattern, rather than being an assertion about what holds about such a pattern. In other words, the proposal is to change the meaning of x: X in a case pattern from a type assertion (assert ... x is X) to a type test (if ... x is X).

Background

Case patterns can introduce bound variables. Most often, these variables are just named and their types are inferred. For example, given the usual

datatype List<T> = Nil | Cons(head: T, tail: List<T>)

and given xs: List<int>, the statement

match xs
case Nil =>
case Cons(x, rest) =>

infers x to have type int and rest to have type List<int>.

The bound variables are allowed to be given a type explicitly. For example, x can be declared explicitly to have type int as follows:

match xs
case Nil =>
case Cons(x: int, rest) =>

The semantics of giving such a type explicitly is that the program text asserts that any value that fits in the structural pattern given has the indicated type. For example,

match xs
case Nil =>
case Cons(x: nat, rest) =>

is saying that if xs has the form Cons(_, _), then xs.head is a nat. If this match statement is executed with xs being Cons(2, Cons(-3, Nil)), then it will bind x as 2. As another example, the program is in error if this match is reached with xs being Cons(-1, Nil) (as usual, the verifier will generate proof obligations to show that a program does not exhibit such errors).

Proposed new semantics

The proposal is to change the semantics of an explicitly given type to make the case apply only if the corresponding value has the given type. Under the proposed semantics, a case like case Cons(x: nat, rest) => would be taken only if xs is of the Cons variant and the head of the Cons is a nat. For an xs value like Cons(-1, Nil), the case would not be taken; instead, the next case in order will be considered (and, in the usual manner, the program is in error if no case applies).

Use case

Under the proposed semantics, one can write the following:

match xs
case Nil =>
case Cons(x: nat, rest) =>
  // this case is taken if xs.Cons? and xs.head is a nat
case Cons(y, rest) =>
  // this case is taken if the previous cases don't apply and xs.Cons?

Another use case

As another use case, consider the following types:

trait Tr { }
class A extends Tr { }
class B extends Tr { }

and suppose trs has type List<Tr>. Then, the following match statement gives separate cases for the head of the list being an A or a B:

match trs
case Nil =>
case Cons(a: A, rest) =>
case Cons(b: B, rest) =>

Under the proposed semantics, this match statement is semantically equivalent to

if trs == Nil {
} else if trs.Cons? && trs.head is A {
  var a: A := trs.head as A;
  var rest: List<Tr> := trs.tail;
} else if trs.Cons? && trs.head is B {
  var b: B := trs.head as B;
  var rest: List<Tr> := trs.tail;
} else {
  assert false;
}

In contrast, under the current semantics, it is semantically equivalent to

if trs == Nil {
} else if trs.Cons? {
  assert trs.head is A;
  var a: A := trs.head as A;
  var rest: List<Tr> := trs.tail;
} else if trs.Cons? {
  assert trs.head is B;
  var b: B := trs.head as B;
  var rest: List<Tr> := trs.tail;
} else {
  assert false;
}

where the second trs.Cons? case will never be taken.

Some details

Type tests

The proposed semantics involves a type test, rather than a type assertion. Such a type test is not always possible at run time, because the test may involve a ghost expression (in the constraint of a subset type) or may involve non-injective type parameters of reference types. Dafny already restricts such run-time tests elsewhere (for example, in comprehensions where a type test is needed, and in is expressions for reference types). Thus, if the match occurs in a compiled context, then these same restrictions apply to explicitly typed bound variables.

(The last case for a given structure does not require run-time type tests, since anything of that structure is verified to have that type. Nevertheless, I propose we don't treat this special case any differently.)

Redundant cases

The proposal affects the redundant-case checks. In particular, a warning about a redundant case should be generated only if there's a previous case whose types are supertypes. This should be done nominally (that is, without involving the verifier).

For example, given

type Even = x: int | x % 2 == 0
type Odd = x: int | x % 2 == 1

and xs: List<int>, we have

match xs
case Nil =>
case Cons(e: Even, rest) =>
case Cons(e: Even, rest) => // reported as redundant
case Cons(o: Odd, rest) =>
case Cons(x: int, rest) => // not reported as redundant, since this knowledge requires verification

As another example, given

type SmallInt = x: int | 0 <= x < 10
type SmallNat = x: nat | 0 <= x < 100

and again xs: List<int>, we have

match xs
case Nil =>
case Cons(n: nat, rest) =>
case Cons(s: SmallInt, rest) => // not reported as redundant, since the base type of SmallInt is not nat
case Cons(s: SmallNat, rest) => // reported as redundant
case Cons(x: int, rest) =>
@cpitclaudel
Copy link
Member

Lovely! But surprising for subset types, at least until #1680 is solved: what's the desugared version of Cons(x: nat, …)? (We don't support is for subset types, nor do we expose their predicate as a function)

@RustanLeino
Copy link
Collaborator Author

There is currently no surface syntax like MySubsetType?(x) or x is MySubsetType, which is what #1680 is requesting. Instead, you may have inline the constraint itself. Having such syntax support would make it more straightforward to give a desugaring of the proposed case semantics in terms of Dafny itself. However, even without surface syntax, both the verifier and (with with upcoming #1604) the compiler can generate expressions that do the type test.

@fabiomadge
Copy link
Collaborator

fabiomadge commented Jan 17, 2022

It would be nice to additionally expose this capability to the programmer in a more generic way by allowing a guard in a CaseExpression.

@amaurremi
Copy link
Contributor

I agree that this would be a very useful extension to Dafny. Similarly to @fabiomadge I was wondering whether implementing this would allow us to relatively easily add support for guards in case expressions.

Given that the proposal can report redundant checks, I assume that it would also report errors on non-exhaustive pattern matches, is that correct? E.g. if I write

match trs
case Nil =>
case Cons(a: A, rest) =>

would it report that case Cons(a: B, _) is missing?

And, similarly, if I have the following assertion:

assert trs.Cons? && trs.a is B

would it then accept the pattern match and not report the above case as missing?

Such a type test is not always possible at run time, because the test may involve a ghost expression (in the constraint of a subset type) or may involve non-injective type parameters of reference types

Did this intend to say "not always possible at compile time"?

In contrast, under the current semantics, it is semantically equivalent to ...

This was probably meant for explanation purposes, to contrast the proposal with the current semantics, but I believe that Dafny wouldn't allow me to pattern match on two case patterns that differ only in the type ascriptions, because it currently doesn't them as type checks.

@robin-aws robin-aws added kind: enhancement Enhancements, feature requests, etc. These are NOT bugs, but ways we can improve Dafny part: language definition Relating to the Dafny language definition itself labels Jan 17, 2022
@MikaelMayer
Copy link
Member

I keep this in mind while working on #1604

@RustanLeino
Copy link
Collaborator Author

RustanLeino commented Jan 18, 2022

Dafny insists that the given cases are exhaustive. It is an error (in both match constructs and if-case statements) if no case applies. The verifier checks for this. This will not change.

The "redundant case" message is just a warning. It works accurately today, because we can syntactically distinguish the cases.

Under the proposed extension, it becomes more difficult to determine if a case a redundant, since case selection can depend on the types of values. Hence, I suggested that Dafny will use the declared base-type hierarchy of subset types (rather than the constraints given in subset-type declarations) to determine whether or not to give a "redundant case" warning (see the SmallInt example above). This means that there may be some unreachable cases that do not get flagged as "redundant case".

If we take yet another step and let the cases of a match give constraints, then redundant-case warnings become even more difficult to produce. If such constraints make for a useful feature that we decide to add, then I suggest that it take priority over producing accurate redundant-case warnings. In other words, if we (now or in the future) were to allow logical constraints as part of match cases, then I suggest that redundant-case warnings be produced only when it is clear from syntax and the declared base-type hierarchy that a case is redundant. There may then be unreachable cases for which there is no warning (which is also the case in the rest of Dafny).

@RustanLeino
Copy link
Collaborator Author

@amaurremi

Such a type test is not always possible at run time, because the test may involve a ghost expression (in the constraint of a subset type) or may involve non-injective type parameters of reference types

Did this intend to say "not always possible at compile time"?

There are type tests that cannot be performed at run time (and hence the compiler cannot emit code for doing it). For example, consider

datatype Flavor = Strawberry | Licorice | Vanilla
datatype IceCream = IceCream(flavor: Flavor, ghost popular: bool)
type PopularIceCream = c: IceCream | c.popular witness *

method Example(c: IceCream) {
  if c is PopularIceCream { // cannot be tested at run time
  }
}

It is not possible to test c is PopularIceCream at run time, because the ghost field popular is not available at run time.

@RustanLeino
Copy link
Collaborator Author

@amaurremi

In contrast, under the current semantics, it is semantically equivalent to ...

This was probably meant for explanation purposes, to contrast the proposal with the current semantics, but I believe that Dafny wouldn't allow me to pattern match on two case patterns that differ only in the type ascriptions, because it currently doesn't them as type checks.

(There was a typo in my example, where I had written rest instead of trs.trail. I fixed it now, but I don't think this is what led to any confusion. I also clarified the "Another Use Case" section by inserting the phrase "Under the proposed semantics,".)

The match statement I show in the use case as well as both of the desugarings shown are possible to write in Dafny today. However, you will get some verification errors, and the third case also produces a redundant-case warning.

@cpitclaudel
Copy link
Member

An alternative to this approach is to some form of path-sensitive typing. We already have something like this in Dafny with null: inside a x != null branch x is known not to be null. We could make it so that inside an x is T branch x has type meet(T, Tx), where Tx is x's previously known type (for reference types) and we could assume the subset predicate within the branch (for subset types).

@RustanLeino
Copy link
Collaborator Author

Dafny does have the effect of path-sensitive typing, like in the example you gave. But it's not the type system that does this. Instead, the type is usually the base type (like T?) and whenever the type needs to be more specific (like T), the verifier checks that it satisfy the necessary subset-type constraint. For example, you can write

method M(b: bool, x: T?)
  requires b ==> x != null
{
  if b {
    // here, you can use x as if it had the non-null type T
  }
}

Note that x != null does not appear syntactically as the guard here.

@robin-aws
Copy link
Member

I'm actually quite concerned that interpreting x: T as a type test rather than a type constraint is going to be very confusing for users. The language does not interpret that syntax with the former semantics anywhere else, instead using x is T. Even if changing this meaning is technically backwards compatible, that doesn't mean that Dafny user brains will easily make the switch. :)

I think I prefer the alternative of only supporting guard expressions instead, for clarify and consistency. Something like:

trait Tr { }
class A extends Tr { }
class B extends Tr { }

match trs
case Nil =>
case Cons(a, rest) where a is A =>
case Cons(b, rest) where b is B =>

Exhaustiveness just implies another proof obligation. If the desugared guards on a match statement/expression (assuming we can express a pattern like Const(a, rest) as something like trs.Cons?) are A, B, C and D, then the obligation would be something equivalent to !A && !B && !C ==> D. AFAICT that's the only necessary constraint since cases can overlap (and hence ordering is significant). The challenge then becomes more helpful error messages in the common cases, but I think it would be very compelling to support arbitrary guards with the safety net of a verifier to catch incorrect guards. This would also align nicely with the path-sensitive typing above.

@amaurremi
Copy link
Contributor

The language does not interpret that syntax with the former semantics anywhere else, instead using x is T

I'm curious what kind of cases you have in mind, could you give an example of other Dafny features/patterns that would have a different flavour?

If the verifier provides exhaustiveness errors and redundant-case warnings, would the users not immediately understand what's going on?

@cpitclaudel
Copy link
Member

I'm actually quite concerned that interpreting x: T as a type test rather than a type constraint

But the proposal is to interpret it as a type constraint: the branch is only taken if the constraint is satisfied. No?

Using where a is A is also confusing, because a is A is a test, not a cast, so the expectation might be that a will have type Tr in the branch (though arguably there's precedent in C# with path-sensitive types). If we do go that route, then we would have to change the semantics of "is" in a branch, too.

Besides the syntax is going to be pretty heavy:

trait Tr { }
class A extends Tr { }
class B extends Tr { }

match tr
case a where a is A =>
case b where b is B =>

@robin-aws
Copy link
Member

I'm actually quite concerned that interpreting x: T as a type test rather than a type constraint

But the proposal is to interpret it as a type constraint: the branch is only taken if the constraint is satisfied. No?

I meant a "constraint" as in type checking - pretend I said "assertion" instead :)

@cpitclaudel cpitclaudel removed their assignment Aug 9, 2022
RustanLeino added a commit that referenced this issue May 1, 2023
This PR makes various changes and improvements to resolution. The need
for these changes was driven by the upcoming new type inference. The PR
also fixes several issues related to the desugaring of `match`
constructs.

Fixes #3908
Fixes #3915
Fixes #3918
Fixes #3921
Fixes #3922 
Fixes #3923

Changes related to `match`:

* Eliminate `ReplaceTypesWithBoundVariables`. This troublesome method
was changing the AST before desugarings were taking place. This made the
up-and-coming pre-type inference more difficult. And as I was fixing
these other bugs related to `match`, I found that I repeatedly had to
counteract what `ReplaceTypesWithBoundVariables` had done. This because
increasingly more complicated, so I decided to address the real problem
and remove `ReplaceTypesWithBoundVariables`. Amazingly, doing so cause
almost no problems by itself. My conclusion is that this method was here
only for historical purposes, no longer for anything useful.
* Print attributes and types of case patterns
* **fix:** Fix issues with temporary variables introduced during
resolution of case patterns (#3908)
* Introduce `VisitExtendedPattern` method
* Visit patterns during visitation of `NestedMatchStmt`
* Include literal expressions in the `.SubExpressions` of cases
* Don't generate (unused) local/bound variable for literal patterns
* Memoize value computed by `OptimisticallyDesugaredLit`
* Print the RHS literal of symbolic literals in case patterns
* **fix:** Mark ghost status of case variables correctly (#3918)
* **fix:** (**breaking change**) Don't allow downcasts from traits in
case patterns (#3922). These cases previously caused the C# and Java
compiler to emit malformed code. Rather than changing those compilers, I
took the opportunity to disallow such downcasts during resolution. In
the future, we plan to introduce some kind of type-case statement, which
is a better solution for these things, anyway (see also
#1715). But, technically, this
is a breaking change for anyone who was using the old behavior with the
JavaScript, Go, or Python compilers.
* **fix:** Fix crash that occurred due to malformed desugaring when
encountering `_: int` in pattern (#3923)

Other changes:

* Make working method `CreateResolvedCall` more general to support
parameters and type parameters
* Include `ObjectToDiscard` in the `.SubExpressions` of
`StaticReceiverExpr`
* Generate proper scaffolding around `MemberSelectExpr` and
`FunctionCallExpr` when desugaring iterators
* Refactor some code in the resolution of `AssignOrReturnStmt` to make
routines reusable by pre-resolution
* Move the computation of loop-body surrogate away from the early
name-resolution/type-inference pass. (A consequence of this is that the
warning messages for body-less loops appear in a later phase of
resolution than before.)
* (**breaking change**) Move assumption-variable checks away from the
early name-resolution/type-inference pass. A breaking-change consequence
of this is that type-less assumption variables are no longer inferred to
have type `bool`. (This breaking change is justified as follows:
Assumption variables are rarely, if ever, used. The inference that was
removed only affects assumptions variables that weren't used for
anything, because any other proper use would cause them to be inferred
to be of type `bool`. The breaking-change fix is, at worst, to manually
add the type `: bool` to the declaration of each assumption variable.)
* Make introduction of members of built-in types (like `.Floor` and
`.IsLimit`) more uniform
* **fix:** Fix `:autoReq` to include type parameters of function calls
* **fix:** Re-include the `examples/induction-principle-code/` tests,
which had been "temporarily" excluded for a while. (A recent restriction
that affects those test cases is fixed in this PR.)
* (**breaking change**) Use of `this` when dereferencing static members
is now disallowed in the first phase of object constructors. This makes
the treatment of `this` in that first phase more uniform.
* Change a couple of test cases to no longer rely on cast-free
conversions from a trait to a class. This is in preparation for the new
type inference, which will insist on having such casts explicitly.
* Fix: Perform size checks of bitvector literals in case patterns
(#3915). (At some point in the past, the `git-issue-889b.expect` file of
these tests had been changed, which caused the introduction of this bug
to go unnoticed sooner.)
* **fix:** Check for underspecified type arguments of subset types
(#3921)

<small>By submitting this pull request, I confirm that my contribution
is made under the terms of the [MIT
license](https://github.com/dafny-lang/dafny/blob/master/LICENSE.txt).</small>

---------

Co-authored-by: Remy Willems <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
kind: enhancement Enhancements, feature requests, etc. These are NOT bugs, but ways we can improve Dafny part: language definition Relating to the Dafny language definition itself
Projects
None yet
Development

No branches or pull requests

6 participants