dart-lang / language

Design of the Dart language
Other
2.65k stars 202 forks source link

Allow compile-time argument validity checks when said arguments are compile-time constant #1294

Open AKushWarrior opened 3 years ago

AKushWarrior commented 3 years ago

(The title may be slightly misleading. When I speak about dependent types, I'm talking about the idea of, at compile time, allowing restrictions on arguments to a parameter beyond those imposed by the parameter type. This proposal isn't really for dependent types; it's more achieving one of the key advantages of dependent types, in allowing programmers to require a specific subset of a given type.) Fixed the title.

A const value is known at compile-time. We can avoid a class of runtime errors, then, by allowing programmers to specify conditions under which a const argument is valid (as opposed to manually checking the parameter in a method or function, and throwing if the parameter is invalid). There's plenty of cases where an invalid constant could be passed to a method, but the caller would not recognize this until runtime:

double divide (int dividend, int divisor) {
   if (divisor == 0) throw ArgumentError("can't divide by zero");
   return dividend/divisor;
}

void main (List<String> args) => print(divide(0, 0)); //throws an error at runtime, doesn't even hint at compile time

Such methods which are part of core libraries are simple enough to fix; you can simply use static analysis to provide a hint to the caller when they attempt to pass obviously invalid parameters. However, for third-party methods (say, in a package), these restrictions can't be hardcoded into static analysis.

To fix this, I propose a simple, optional addition to Dart function declarations: a second set of parentheses which declare a compile time constant boolean expression involving one or more parameters, where true is valid and false is invalid. The syntax might look like this:

double divide (int dividend, int divisor) (assert divisor != 0) => dividend/divisor;

// Compiler sees that divide function, which has a compile-time check on the value of divisor, has a constant value as 
// the positional argument for divisor. It thus checks that the argument fulfills the compile-time check; if it doesn't, the
// compiler errors before the program reaches runtime.
void main (List<String> args) => print(divide(0, 0));

Now, this example is best-case, where the user passes a const argument and the compiler is able to run the check at compile time. If the argument is not constant, the compiler can simply defer to a runtime check.

import 'dart:math' show Random;

double divide (int dividend, int divisor) (assert divisor != 0) => dividend/divisor;
int divisor = Random().nextDouble().round();

// Compiler sees that divide function has a compile-time check on the value of divisor, but sees that the argument for 
// divisor is not compile time constant. It then defers the check to runtime, and throws an error if the check fails at 
// runtime. In other words, it just injects the check to the top of the method, essentially falling back to the current
// method of error checking.  
void main (List<String> args) => print(divide(3, divisor));

EDIT: I just thought of another case, where there is multiple arguments that need to be checked, and their checks are entirely separate logically. It wouldn't make sense to not check one argument because another argument is not compile-time constant. Instead, we could do something like this:

import 'dart:math';

List<int> slice (List<int> original, int start, int stop) (assert start >= 0, stop >= 0) => original.sublist(start, stop);

void main (List<String> args) {
   print(slice([0, 1, 2], 0, 2); //fine
   print(slice([0, 1, 2], -1, 2); //compile time error

   int random = Random().nextInt(3);
   // This still is a compile time error, even though the second argument isn't const, because there is two independent
   // checks on slice. One has been deferred (since the second argument is not const); the other fails at compile-time.
   print(slice([0, 1, 2], -1, random); 
}

Some Notes:

The grammar for the check may prove difficult to implement as I've constructed it, but the placement of the check isn't all that important. The check could even be contained in an annotation above the method, which is less clean in my opinion but also is a less fundamental change to the language.

I think that all my claims about the current state of the Dart language are accurate. If not, please let me know.

I believe that this feature will help immensely in Flutter, which often has arbitrary restrictions on the range of values for a parameter that aren't immediately clear to the user.

eernstg commented 3 years ago

The notion of compile-time evaluation is supported in Dart by the constant expressions that you already mention. They allow for computations involving certain built-in types, plus invocation of const constructors.

It is always a complex enhancement to allow constant expression evaluation to include some extra elements. In this particular case there's a need for detecting that actual arguments to a function invocation are constants, evaluating the asserts at the beginning of the function body. More complexity is implied if it should be supported that some actual arguments are constant expressions and others are not, and exactly the assertions that only evaluate potentially constant expressions (based on which actual arguments are constant) should be checked at compile-time. So this could easily grow to be a rather complex feature.

Hence, it's worthwhile to keep in mind how something similar could be expressed within the current feature set. Here's a workaround which can be used for full compile-time evaluation:

class Divide {
  final double result;
  const Divide(int dividend, int divisor)
      : assert(divisor != 0),
        result = dividend / divisor;
  double call() => result;
}

void main(List<String> args) =>
    print(const Divide(0, 0)()); // Compile-time error.

If the computation cannot be a constant expression then call could contain whatever is needed:

class Divide {
  final int dividend, divisor;
  const Divide(this.dividend, this.divisor): assert(divisor != 0);
  double call() {
    // ... regular Dart code ...
    return dividend / divisor;
  }
}

The scenario where some arguments are constants and others are not isn't covered so easily. It would be possible to use more than one class and some currying to give compile-time constant arguments and run-time arguments separately, but it's probably far too verbose and difficult to read to be used in practice.

Hence, the extensions that would add the greatest amount of value are also the ones that add a lot of complexity, which might not be a surprise. ;-)

AKushWarrior commented 3 years ago

I thought about the class-based workaround, but, as you said, it tends to grow in verbosity really quickly as we advance to the cases where this feature might make the most difference. It's also not really idiomatic Dart code, and so the potential advantages of writing code like this is probably outweighed by the issues.

I have little experience in compiler design, so I'll take your word that this feature is likely to be high in implementation complexity given the way I've currently specified it.

My reasoning was primarily along the lines that, in its current iteration, Dart highly values compile-time safety to limit runtime errors. A feature like this should come in handy fairly regularly, since most practical programs have limitations on the range of values that are reasonable for a given parameter; this is especially relevant in graphical programs, where this range is often arbitrary and not especially clear from the method name alone.

lrhn commented 3 years ago

The biggest issue I see with this idea is that it only work for statically resolved functions (constructors, static, top-level and extension methods). It cannot work for instance methods, which are still the vast majority of functions being called in an object oriented language like Dart, because you don't know which actual method is going to be called (at least not unless the receiver also happens to be a constant value). That alone reduces the potential usefulness and viability of the feature.

Nothing currently prevents the analyzer from recognizing constant arguments to known function, and checking whether any initial if (test(theVar)) throw SomeError or known ArgumentError.checkSomething(theVar) compile-time determinable checks in the function body will definitely throw for the provided value. It'd be perfectly fair to give warning for such cases.