Short description
match
constraint to match on a value and apply different constraints based on the value.
Example:
{some_type}
match someFunction(input_values)
| case SomeConstructor() { false | error $[That's not right], some_type == UnkownType() }
| case AnotherConstructor(returned_values) { finagleThings(returned_values) == some_type }
| case YetAnotherCase(some_other_values, some_type) { doSomethingElse(some_other_values) },
use_type(some_type).
Problem description.
This avoids helper functions for two common issues. Helper functions are annoying because they require a few extra lines, but more importantly they are annoying to change. If I need an extra parameter from the main function in the helper function, I need to add it to the call, signature and every case of the helper function. With match
all variables defined in the main function are available directly.
The first issue is simply matching on things. For example, functions in the PIE DSL have a list of type arguments. This list can either be explicit (supplier<int>(7)
) or omitted (supplier(8)
).
Supplier is built-in because reasons, so there is explicit code that handles all the cases for the number of type arguments and normal arguments (as opposed to getting handled by code for regular functions):
typeOfExpImpl(s1, CreateSupplier(_, NoTypeArgs(), [exp])) = (s2, SupplierType(ty)) :-
typeOfExp(s1, exp) == (s2, ty).
typeOfExpImpl(s1, CreateSupplier(_, TypeArgs([arg_ty_lex]), [exp])) = (s2, SupplierType(arg_ty)) :-
{exp_ty}
typeOf(s1, arg_ty_lex) == arg_ty,
typeOfExp(s1, exp) == (s2, exp_ty),
assignableTo(exp_ty, arg_ty) | error $[Type mismatch: argument with type [exp_ty] is not assignable to type argument [arg_ty]] @exp.
typeOfExpImpl(s, CreateSupplier(name, type_args, args)) = (s, DataType(emptyScope(s))) :-
typeOfCreateSupplier_1(type_args, args),
try { args != [] } | error $[Not enough arguments. Expected one argument, but got zero] @name,
try { args != [_,_|_] } | error $[Too many arguments. Expected one argument, but got multiple] @name.
typeOfCreateSupplier_1 : TypeArgs * list(Exp)
typeOfCreateSupplier_1(NoTypeArgs(), _).
typeOfCreateSupplier_1(type_args@TypeArgs([]), args) :-
false | error $[Not enough type arguments. Expected one type argument, got zero. To infer the type argument, omit the type argument list: `supplier([args])`] @type_args.
typeOfCreateSupplier_1(type_args@TypeArgs([_]), _).
typeOfCreateSupplier_1(type_args@TypeArgs([_,_|_]), _) :-
false | error $[Too many type arguments. Expected one argument, got multiple] @type_args.
This could be simplified with a match:
typeOfExpImpl(s1, CreateSupplier(name, type_args, args)) = (s2, SupplierType(ty)) :-
match args
| [] { false | error $[Not enough arguments. Expected one argument, but got zero] @name, s2 == s1, arg_ty == UNKOWN() }
| [arg] { typeOfExp(s1, arg) == (s2, arg_ty) }
| [_,_|_] { false | error $[Too many arguments. Expected one argument, but got multiple] @name, s2 == s1, arg_ty == UNKOWN() },
match type_args
| (NoTypeArgs(), _) { ty == arg_ty}
| TypeArgs([]) { false | error $[Not enough type arguments. Expected one type argument, got zero. To infer the type argument, omit the type argument list: `supplier([args])`] @type_args, ty == arg_ty }
| TypeArgs([type_arg]) { partial match args | case [arg] { assignableTo(arg, type_arg) }, ty == type_arg }
| TypeArgs([_,_|_]) { false | error $[Too many type arguments. Expected one argument, got multiple] @type_args, ty == arg_ty }.
The second problem is to only run a function if an earlier function succeeded (can be seen from the return type). The current solution for that is to create a helper function which does the matching. In the following example, we only want to check that the argument types match the parameter types if the function resolved. (this example is a simplified version of what happens for the PIE DSL).
typeOfCall(s, Call(name, args)) = typeOfCall_1(s, resolveUniqueFunc(s, name), typeOfExps(s, args), name).
typeOfCall_1 : scope * ResolutionResult * list(TYPE) -> TYPE
typeOfCall_1(_, NoDefinitions(), _, name) = UnkownType() :-
false | error $[Undefined function [name]] @name.
typeOfCall_1(s, Definition(param_tys, return_ty), arg_tys, _) = return_ty,
subtypes(arg_tys, param_tys).
typeOfCall_1(_, MultipleDefinitions(_), _, name) = UnknownType() :-
false | error $[Duplicate definitions for function [name]] @name.
With matching, it could be shortened to this
typeOfCall(s, Call(name, args)) = return_ty :-
typeOfExps(s, args),
match resolveUniqueFunc(s, name)
| case NoDefinitions() { false | error $[Undefined function [name]] @name, return_ty == UnkownType() }
| case Definition(param_tys, return_ty) { subtypes(arg_tys, param_tys) }
| case MultipleDefinitions(_) { false | error $[Duplicate definitions for function [name]] @name, return_ty == UnkownType() }.
Describe the solution you'd like
// match
$PartialOpt match $Term $MessageOpt
$Cases
// match on a term and give cases that the term may deconstruct to.
// Cases is a list of `Case`s, separated by newlines
// MessageOpt is the optional message that will be used if the match itself fails, similar to normal constraints, e.g.
// match some_term | error $[Could not match on [some_term]] <cases on following lines>
// Case
| case $Term $CaseConstraints // The matched term and the constraints for this case.
// CaseConstraints
// leaving it empty means no case constraints.
{ $Constraints } // constraints enclosed in curly braces, e.g.
// { typeOfParams(params) == param_tys, subtypes(arg_tys, param_tys) }
| $Severity $MessageText // syntactic sugar for `{ false | $Severity $Message }` e.g.
// | error "this case is an error"
Checking which case is hit can use the same logic that is used to determine which rule of a function should be called.
Any variables within case terms will be unified with the regular unification magic that Statix employs.
Only variables in the actual case that is hit get unified. This could be important with cases like
| case SomeConstructor(some_var@some_other_var)
, which unifies some_var
with some_other_var
. This should only be done if this case is actually hit.
If there is no case for the actual value of the matched term (or the term is unbound), the match constraint fails (in the same way that a function fails if there is no matching rule). If a message is provided, it will be used with the given severity and message text, similar to normal constraints. The $Message
for the match
constraint itself is on the first line, and not after all the cases, because that would be ambiguous:
match some_var
| case SomeConstructor()
| error "Is this error for the match or for the case?"
Layout suggests that the error is for the match but I think using layout for disambiguation is bad practice.
To ignore missing cases, add the keyword partial
, e.g. partial match some_var
. This will not give errors when there is no case for the value of the term. It should probably still give an error if the term is unbound.
Something that may or may not be possible is to statically verify that there are cases for all possible values, and give a static error in Statix if that is not the case, for example:
match some_list
| case [] | error $[Expected one argument, got zero]
| case [_,_|_] | error $[Expected one argument, got multiple].
Which gives an error on the match keyword (or the entire match constraint, although I feel that is excessive): "Unmatched cases: [_]
. Use partial match
to allow missing cases"
If this is implemented, I suggest adding some way to explicitly allow missing cases but still give a runtime error if they are hit. An option is exhaustive match
. This seems suboptimal because it seems like this would be the one that requires everything exhaustive (instead of match
), but I feel like it would be beneficial to have match
be the one that gives errors, with some modifier to disable these errors, rather than match
not giving errors and requiring a modifier to get the errors.
Finally, we could consider to add syntax for multiple cases at once, e.g. case SomeConstructor() | SomeOtherConstructor() { constraintsForBoth() }
. This particular syntax is probably too much (|
is overloaded enough as it is, I can't imagine that adding this won't introduce an ambiguity), but some other syntax might work.
If there is only a single case where there are multiple constructors one can use an unbound variable, but that does not work if there are multiple such cases:
match some_term
| case C1() | C2() | note "C1 or C2"
| case C3() | C4() | note "C3 or C4"
It may also improve (for certain definitions of improve) the example code above
Describe alternatives you've considered
For the second problem (only add constraint if earlier constraint passed), I have considered a sequencing constraint: C1; C2
Constraint C2
will not be evaluated until C1
passes.
The problem here is that it only works when C1
can either pass or fail. If C1
can pass or fail in multiple ways, it still requires that C2
be put in a helper function.
It's also a bit less clear than match
, especially if the difference between sequential and parallel constrains is wether you separate them with ;
or ,
. The separator could be changed, but still.
Additional context
This would probably remove the need for 80% of helper functions in my code. A simple search for _1
returns 201 results. That includes both the definition and cases, but even if we are generous and say that all of them have 4 cases (1 definition + 4 rules = 5 search results per helper function) , that is still 40 helper functions that can be removed if this is implemented.
Stratego is getting match expressions because it improves performance (I think), maybe it could improve performance for Statix as well? (instead of calling a helper function, the helper function is more or less inlined by the user)
I have considered if there should be a default
case, e.g. | default | note $[Did not match any other case]
, but it seems to me that that is equivalent to just using an unbound variable: | case _ | note $[Did not match any other case]
. Here is an alternative to the example code above that uses such a default case:
typeOfCall(s, Call(name, args)) = return_ty :-
typeOfExps(s, args),
match resolveUniqueFunc(s, name)
| case Definition(param_tys, return_ty) { subtypes(arg_tys, param_tys) }
| case other {
return_ty == UnkownType(),
match other
| case NoDefinitions() | error $[Undefined function [name]] @name
| case MultipleDefinitions(_) | error $[Duplicate definitions for function [name]] @name
}