Editor’s note: This article is a classic overview of Dart air safety. It provides a detailed and comprehensive overview of the principles, implementation, and technical details that Dart addresses air safety adoption, and is a must-see for many Dart developers. Whether you are already a Dart expert or not, you will benefit from reading this. So pour a cup of sweet tea, grab a comfy chair and let’s take you on a safe ride!

By Bob Nystrom, Engineer on the Google Dart team

Dart Development Language, Sound Air Safety, and in-depth Understanding of air Safety

Space safety is the biggest change we’ve made to Dart since replacing the static optional type system with a robust static type system in Dart 2.0. At Dart inception, compile-time space safety was a rare feature that took a lot of time to implement. Today, Kotlin, Swift, Rust, and many other languages have their own solutions, and air security has become a common topic. Let’s take a look at this example:

// Empty security:
bool isEmpty(String string) => string.length == 0;

main() {
  isEmpty(null);
}
Copy the code

If you run the Dart program without null security, it will raise a NoSuchMethodError exception when calling.length. The null value is an instance of the NULL class, and NULL has no getter for “length”. Run-time errors are annoying, especially in the Dart language, which was built for terminals. If a server application becomes abnormal, you can quickly restart it without the user noticing. But when a Flutter app crashes on a user’s phone, their experience is severely compromised. If users aren’t happy, developers shouldn’t be either.

Developers prefer statically typed languages like Dart because they often allow IDE developers to catch errors through type checking. The sooner a Bug is discovered, the sooner it can be dealt with. When language designers talk about “fixing null reference errors,” they mean strengthening static type checkers so that errors such as calling.length on a value that might be null are detected.

There has never been a standard answer to this question. Rust and Kotlin each have a reasonable solution within their language. This document will walk you through the Dart solution. It includes changes to the static typing system and many other aspects, as well as new language features that let you not only write null-safe code, but also have a lot of fun writing code.

This document is very long. If all you need is a short document on how to get up and running, start with an overview. Come back here when you think you’ve had enough time and are ready to dig deeper to see how the language handles NULL, why we designed it the way we did, and how you can write space-safe Dart code in line with modern conventions. (A spoof: It’s actually very similar to how you currently write Dart code.)

There are pros and cons to dealing with null-reference errors. We make our choices based on the following principles:

  • The code is secure by default. If you write new code that does not explicitly use unsafe features, the runtime will not throw a null-reference error. All potential null reference errors will be caught statically. If you want to put some checking into the runtime for flexibility, that’s not a problem, but you have to explicitly use some functionality in your code to get what you want.

    In other words, we don’t give you a life jacket to remember to wear every time you go out to sea. Instead, we offer you an unsinkable boat, and as long as you don’t jump in, nothing will happen.

  • Null-safe code should be easy to write. Most existing Dart code is dynamically correct and does not throw a null-reference error. You really like the way you’re writing Dart code now, and we hope you’ll continue to write code that way. Security should not require ease of use to be compromised, more time spent on type checkers, or significantly change the way you think.

  • The resulting null-safe code should be very robust. For static checking, “sound” has multiple meanings. For us, in the context of null-safety, “sound” means that if an expression declares a static type that does not allow null, then no execution of that expression can be null. The Dart language ensures this feature primarily through static checking, but some checking is also involved at run time. (However, according to the first rule, when and where to check at run time is entirely up to you.)

    The integrity of your code greatly determines how confident you are in your code. A boat that is drifting most of the time is not enough to summon up the courage to venture out to the high seas. This is also important for our intrepid “hacker” compiler. When a language makes hard guarantees about semantically defined properties in a program, the compiler can actually optimize those properties. When it comes to NULL, it means that unnecessary NULL checks can be eliminated, code can be more concise, and it does not need to be checked for air conditioning before calling methods on it.

    One caveat: Currently we can only fully guarantee the health of code that uses null security. The Dart application supports a mix of new null-safe code and old legacy code. In these “mixed mode” programs, null-reference errors are still possible. This type of program allows you to use empty security in the part, enjoy all static parts of the empty security benefits. But until null-safe is used throughout the program, the code is not guaranteed to be null-safe at runtime.

It is important to note that our goal is not to eliminate NULL. There is nothing wrong with null. Conversely, a value that can represent a vacancy is very useful. Provide support for the value of the vacancy in the language, making it more flexible and efficient to handle the vacancy. It is an optional argument,? .air conditioning provides the basis for initialization with syntax sugar and default values. Null isn’t bad; what’s bad is that it shows up in unexpected places and causes problems.

Therefore, for null security, the goal is to make null in your code visible and controllable, and to make sure it doesn’t get passed somewhere and cause a crash.

Nullability in type systems

Because everything is built on a static type system, empty safety starts here, too. Your Dart program contains the entire world of types: primitive types (such as int and String), collection types (such as List), and classes and types defined by you and the dependencies you use. Before the introduction of null safety, the static type system allowed null everywhere in expressions of all types.

From a type theory perspective, the Null type is considered a subclass of all types;

A type defines a number of operation objects, including getters, setters, methods, and operators, to be used in expressions. If it is a List type, you can call.add() or [] on it. If it is an int, you can call + on it. But null values don’t have any of the methods they define. So when NULL is passed to another type of expression, any operation is likely to fail. This is the crux of empty references — all errors come from trying to find a nonexistent method or property on NULL.

Non-null and nullable types

Air security addresses this problem at its root by modifying the type hierarchy. The Null type still exists, but it is no longer a subclass of all types. The type hierarchy now looks like this:

Since Null is no longer considered a subclass of all types, it is not allowed to pass Null values except for the special Null type. We have set all types to non-nullable by default. If your variable is of type String, it must contain a String. In this way, we have fixed all null reference errors.

If null doesn’t mean anything to us, we don’t need to go any further. But null is actually quite useful, so we still need to handle it reasonably. Optional arguments are a good example. Let’s take a look at the null-safe code:

// Use empty security:
makeCoffee(String coffee, [String? dairy]) {
  if(dairy ! =null) {
    print('$coffee with $dairy');
  } else {
    print('Black $coffee'); }}Copy the code

Here we want the dairy parameter to be passed an arbitrary string, or a null value. To express our ideas, we add? To the end of the original String. Makes dairy a nullable type. In essence, this is no different from defining a combined type with Null. So if Dart contains the full composition type definition, then String? String is the abbreviation of | Null.

Use nullable types

If your expression might return a null value, what do you do with it? Since safety is one of our principles, there are few answers left. Because you will fail to call a method when its value is null, we will not allow you to do so.

// Imaginary void security:
bad(String? maybeString) {
  print(maybeString.length);
}

main() {
  bad(null);
}
Copy the code

If we allow such code to run, it will undoubtedly crash. We only allow you to access methods and properties defined under both the old type and the Null class. So only toString(), ==, and hashCode are accessible. Therefore, you can use nullable types for Map key values, store them in collections, or compare them to other values, and that’s all.

So how do primitive types interact with nullable types? We know that it is safe to pass a value of a non-null type to a nullable type. What if a function takes a String? , then passing String to it is allowed without any problems. In this change, we subclass all nullable types as base types. You can also pass NULL to a nullable type, which is a subclass of any nullable type:

But it is not safe to pass a nullable type to a non-nullable base type. Variables declared as strings may call String methods on values you pass. If you pass String? If null is passed in, an error may occur:

// Imaginary void security:
requireStringNotNull(String definitelyString) {
  print(definitelyString.length);
}

main() {
  String? maybeString = null; // Maybe not
  requireStringNotNull(maybeString);
}
Copy the code

We will not allow such unsafe procedures. However, implicit transformations are always present in Dart. Suppose you pass an instance of type Object to a function that requires a String. The type checker allows you to do this:

// Empty security:
requireStringNotObject(String definitelyString) {
  print(definitelyString.length);
}

main() {
  Object maybeString = 'it is';
  requireStringNotObject(maybeString);
}
Copy the code

To maintain robustness, the compiler silently adds the AS String cast to the requireStringNotObject() parameter. Converting at run time might throw an exception, but Dart allows it at compile time. Implicit conversions allow you to pass a String to something that needs a String, provided the nullable type has become a subclass of a non-nullable type. . This promise from an implicit transformation is inconsistent with our security goals. So when air security was introduced, we removed implicit conversions entirely.

This causes the requireStringNotNull() call to produce the compilation error you expect. It also means that all implicit conversion calls like requireStringNotObject() become compilation errors. You need to add an explicit cast yourself:

// Use empty security:
requireStringNotObject(String definitelyString) {
  print(definitelyString.length);
}

main() {
  Object maybeString = 'it is';
  requireStringNotObject(maybeString as String);
}
Copy the code

Overall, we think this is a very good change. Under our impression, most users hate implicit conversions. You may have suffered from it:

// Empty security:
List<int> filterEvens(List<int> ints) {
  return ints.where((n) => n.isEven);
}
Copy the code

See the problem? The.where() method is lazily loaded, so it returns an Iterable instead of a List. This code will compile normally, but will throw an exception at runtime indicating that you encountered an error while converting Iterable to the return type List declared by filterEvens. After the implicit conversion is removed, this becomes a compilation error.

Who am I? Where am I? Where were we? So just as we split all types in half in the genre world:

There is a non-null type of partitioning. The types in this area have access to all the methods you want, but not null. Then there is a parallel family of nullable types. They allow null, but you don’t have much room to manipulate them. It is safe to move values from the non-null side to the nullable side, but not vice versa.

In this case, the null type is basically declared useless. They don’t contain any methods, but you can’t get rid of them. Don’t worry, we have a whole set of methods to help you move values from the nullable half to the other half.

Top and bottom

This section is going to be a little bit more profound. Unless you are very interested in type systems, you can skip this section, and there are two more interesting things at the end of this article. Imagine a program in which all types are subclasses or superclasses of each other. If their relationship were represented as a graph, like the ones in this article, it would be a huge digraph, with superclasses like Object at the top and subclasses at the bottom.

If the top of the digraph is a single superclass (directly or indirectly), then this type is called a top-level type. Similarly, if there is an odd type at the bottom that is a subclass of all types, that type is called the underlying type. In this case, your digraph is a “lattice.”

Having top-level and low-level types in a type system gives us a degree of convenience, because it means that type-level operations such as minimum upper bounds (type reasoning often deduces a type from two branches of a conditional expression) must be able to derive a type. Before the introduction of Null safety, the top-level type in Dart was Object and the low-level type was Null.

Because Object is no longer nullable, it is no longer a top-level type. Null is no longer a subclass of it. There is no well-known top-level type in Dart. If you need a top-level type, you can use Object? . Similarly, Null is no longer an underlying type, otherwise all types would still be nullable. Instead, there is a whole new underlying type Never:

Based on experience in actual development, this means:

  • If you want to indicate that a value can accept any type, use theObject?Rather thanObject. useObjectThis makes the code behave very strangely because it means it can be “in addition tonullAny instance other than “.
  • In the rare cases where low-level types are required, useNeverInstead ofNull. If you don’t know if you need an underlying type, you basically don’t need it.

Ensure correctness

We divide the world of types into two halves, non-empty and nullable. To keep the code sound and our principle that “you never get a null-reference error at run time unless you need it”, we need to ensure that NULL does not appear in any type on the non-empty side.

By replacing implicit conversions and not using Null as the underlying type, we have covered all the major places in the program such as declarations, function arguments, and function calls. Null can now creep in only when a variable first appears and you jump out of a function. So we also see some additional compilation errors:

Invalid return value

If the return type of a function is non-empty, then the function must eventually call return to return a value. Before the introduction of null safety, Dart was very lax in limiting functions that did not return content. Here’s an example:

// Empty security:
String missingReturn() {
  // Return is not used here
}
Copy the code

If the parser checks this function, you’ll see a slight reminder that you may have forgotten the return value, but it doesn’t matter if you don’t. This is because Dart implicitly returns a NULL at the end of code execution. Because all types are nullable, this function is safe at the code level, although it may not necessarily be what you expect.

With a definite nonnull type, the program is faulty and unsafe. Under null safety, if a function that returns a value of a non-null type does not reliably return a value, you will see a compilation error. By “reliable,” I mean that the parser analyzes all control flows in the function. As long as they all return content, the condition is satisfied. The parser is clever enough that the following code can handle it:

// Use empty security:
String alwaysReturns(int n) {
  if (n == 0) {
    return 'zero';
  } else if (n < 0) {
    throw ArgumentError('Negative values not allowed.');
  } else {
    if (n > 1000) {
      return 'big';
    } else {
      returnn.toString(); }}}Copy the code

We’ll take a closer look at the new process analysis in the next section.

An uninitialized variable

Dart initializes a variable to NULL by default if you don’t pass an explicit initialization when you declare it. This is very convenient, but in the case of nullable variables, it is obviously very insecure. Therefore, we need to strengthen the handling of non-empty variables:

  • Top-level variables and static fields must contain an initialization method. Since they can be accessed from anywhere in the program, the compiler cannot guarantee that they have been assigned a value before being used. The only safe option is to require that the initialization expression itself be included to ensure that a value of the matching type is produced.

    // Use empty security:
    int topLevel = 0;
    
    class SomeClass {
      static int staticField = 0;
    }
    Copy the code
  • The fields of an instance must also include an initialization method when declared, either in the usual initialization form or in the constructor of the instance. This type of initialization is very common. Here’s an example:

    // Use empty security:
    class SomeClass {
      int atDeclaration = 0;
      int initializingFormal;
      int initializationList;
    
      SomeClass(this.initializingFormal)
          : initializationList = 0;
    }
    Copy the code

    In other words, the field is assigned before the constructor executes.

  • Local variables are the most flexible. A non-empty variable does not necessarily require an initialization method. Here’s a good example:

    // Use empty security:
    int tracingFibonacci(int n) {
      int result;
      if (n < 2) {
        result = n;
      } else {
        result = tracingFibonacci(n - 2) + tracingFibonacci(n - 1);
      }
    
      print(result);
      return result;
    }
    Copy the code

    The rule to follow here is that local variables must be guaranteed to be assigned before use. We can also rely on the new process analysis mentioned earlier. As long as all paths that use variables are initialized before being used, they can be called normally.

  • Optional parameters must have default values. If an optional positional parameter or an optional named parameter does not pass content, Dart automatically populates it with the default values. In cases where no default value is specified, the default value is null, which causes problems for parameters of non-null types.

    So, if you need an optional argument, either it is nullable or its default value is not NULL.

These restrictions sound cumbersome, but in practice they are not difficult. They are very similar to the limitations associated with the current final, and you may not have paid particular attention to them, but they have been with you for a long time. Also, keep in mind that these restrictions apply only to non-empty variables. When you use nullable types, NULL can still be used as the default value for initialization.

Even so, these rules can give you a little bump in the road. Fortunately, we have a whole new set of language features to help you ride out some of the common bumps. But first, it’s time to talk about process analysis.

Process analysis

Control Flow Analysis has been available in many compilers for many years. It is usually invisible to the user and used only in the compilation optimization process, but some newer languages are beginning to use the same technique in visible language features. Dart has implemented some process analysis in the form of type promotion:

// Use or not use empty security:
bool isEmptyList(Object object) {
  if (object is List) {
    return object.isEmpty; // <-- Lao Tie, right!
  } else {
    return false; }}Copy the code

Notice how we call isEmpty on object on the tag line. This method is defined in a List, not an Object. This code is valid because the type checker checks all is expressions in the code and the path of the control flow. If the body of the partial control flow is executed only if an IS expression of the variable is true, the variables in the code block will be of the derived type.

In this example, the then branch of the if statement is executed only when object is a list. So here Dart elevates the type of Object from its declared object to List. This feature is very convenient, but it has many limitations. Until air safety is introduced, the following programs cannot run:

// Empty security:
bool isEmptyList(Object object) {
  if (object is! List) return false;
  return object.isEmpty; / / < -- error
}
Copy the code

As before, you can only call.isEmpty if object is a list, so this code is actually correct. But the type-promotion rule is not so smart that it does not predict the return and makes the following code accessible only when object is a list.

In air safety, we have enhanced this capability from different dimensions, so that it is no longer limited to analysis.

Accessibility analysis

First, we’ve fixed the long-standing problem of type promotion not being smart enough to handle premature returns and unreachable code paths. When we analyze a function, return, break, throw, and any possible way to end the function prematurely are all taken into account. With empty safety, the following function:

// Use empty security:
bool isEmptyList(Object object) {
  if (object is! List) return false;
  return object.isEmpty;
}
Copy the code

Now it’s completely valid. Since the if statement exits the function when object is not a List, Dart promotes the object type of the next sentence to List. This is a great improvement for a lot of Dart code, even for code that has nothing to do with null safety.

Never for unreachable code

You can code this accessibility analysis yourself. The new underlying type Never has no value. (What can be String, bool, and int at the same time?) So what does an expression of type Never mean? It means that the expression can never be successfully derived and executed. It must throw an exception, interrupt, or ensure that the code calling it never executes.

In fact, according to the language’s fine print, the static type of the throw expression is Never. This type is defined in the core library and you can use it for variable declarations. Perhaps you would write a helper function that simply and conveniently throws a fixed exception:

// Use empty security:
Never wrongType(String type, Object value) {
  throw ArgumentError('Expected $type, but was ${value.runtimeType}. ');
}
Copy the code

You can also use it like this:

// Use empty security:
class Point {
  final double x, y;

  bool operator= = (Object other) {
    if (other is! Point) wrongType('Point', other);
    return x == other.x && y == other.y;
  }

  // Constructor and hashCode etc...
}
Copy the code

This code does not analyze errors. Note that the last line of the == method calls.x and.y on other. Although the first line does not contain a return or throw, its type is promoted to Point. Control flow analysis realizes that the type of wrongType() declaration is Never, indicating that the then branch of the IF statement must be interrupted for some reason. Dart boosts the type of the next line of code because it only runs when other is Point.

In other words, using Never in your code allows you to extend Dart’s accessibility analysis.

Absolute assignment analysis

This analysis was mentioned briefly in the context of local variables. Dart needs to ensure that a non-empty local variable is initialized before it can be read. Absolute assignment analysis is used to ensure that variable initialization is handled as flexibly as possible. The Dart language analyzes the body of the function one by one and tracks the assignment of local variables and parameters to all control flow paths. A variable is considered initialized as long as it has been assigned in each usage path. This analysis allows you to not initialize variables at first, but assign them later in the complex control flow, even for variables of non-null type.

We also use absolute assignment analysis to make variables declared final more flexible. Before null-safety was introduced, some interesting initialization methods were not available when you needed to declare a final variable:

// Use empty security:
int tracingFibonacci(int n) {
  final int result;
  if (n < 2) {
    result = n;
  } else {
    result = tracingFibonacci(n - 2) + tracingFibonacci(n - 1);
  }

  print(result);
  return result;
}
Copy the code

Since result is declared final and contains no initialization, this code returns an error. For more intelligent air security process analysis, this code is correct. Analysis shows that Result is already initialized on all control flow paths, so the constraint is satisfied for the marked final variable.

The type of null check has been improved

Smarter process analysis can be of great help to many Dart code, even code that has nothing to do with nullability. But it’s no coincidence that we’re making these changes now. We’ve divided types into nullable and non-nullable sets, and if a variable is a nullable type, you can’t do anything useful with it. So in the case of null, this restriction is very effective and can prevent your program from crashing.

If the value is not null, it is better to move it directly to the non-empty side so that you can call its methods. Flow analysis is one of the main methods to deal with variables and local variables. We are analyzing == null and! = null expressions are also extended with type promotion.

If you determine whether a nullable variable is not null, Dart raises the variable’s type to its non-null counterpart after the next step:

// Use empty security:
String makeCommand(String executable, [List<String>? arguments]) {
  var result = executable;
  if(arguments ! =null) {
    result += ' ' + arguments.join(' '); }}Copy the code

Here, arguments are nullable types. Normally, calling.join() on them is disallowed. However, since the judgment in the if statement is sufficient to confirm that the value is not null, Dart changes its type from List

? Promoted to List

, allowing you to call its methods or pass it to a function that requires a non-empty List.

It may sound like a small thing, but this process-based null-checking enhancement is the guarantee that most Dart code can run with null-safety. Most of the Dart code is dynamically correct and avoids throwing air conditioning errors by checking for NULL before calling. The new air safety process analysis transforms dynamic correctness into a more guaranteed static correctness.

Of course, it also checks in with smarter analytics. The above function could also be written like this:

// Use empty security:
String makeCommand(String executable, [List<String>? arguments]) {
  var result = executable;
  if (arguments == null) return result;
  return result + ' ' + arguments.join(' ');
}
Copy the code

The Dart language also gets smarter about what expressions need to be promoted. In addition to the explicit == null and! In addition to null, explicitly use as or assignment, and the post-operator we’ll get to in a minute! Type promotion is also done. The overall goal is that if the code is dynamically correct and statically analyzed, the analysis is smart enough to type it up.

Warning of useless code

In your program, a reachability analysis that knows exactly where null is going ensures that you have added null handling. However, we can use the same analysis to detect if you have unused code. Before empty security, if you wrote code like this:

// Use empty security:
String checkList(List list) {
  if(list? .isEmpty) {return 'Nothing';
  }
  return 'There is something';
}
Copy the code

Dart does not know the air escape operator? .useful. It just knows that you can pass NULL into a method. But in empty-safe Dart, if you declare a function as an existing non-empty List type, it knows that the List will never be empty. That’s actually implied, right? . Is not necessary, you can just use.

To help you simplify your code, we’ve added warnings for unnecessary code that static analysis can accurately detect. Use the air escape operator on a non-null type, with == null or! = null judgment, a warning will appear.

You will also see similar hints in the case of non-null type promotions. When a variable has been promoted to a non-null type, you will see a warning for unnecessary NULL checks:

// Use empty security:
checkList(List? list) {
  if (list == null) return 'There's not even a list.';
  if(list? .isEmpty) {return 'Empty list';
  }
  return 'There is something';
}
Copy the code

Here, since the list cannot be null after code execution, you will find the? You see a warning at the call to. These warnings are not just about reducing meaningless code; by removing unnecessary NULL judgments, we ensure that other meaningful judgments stand out. We expect you to see where null is passed in your code.

Dance with nullable types

We have now classified NULL into a collection of nullable types. With process analysis, we can safely get some non-null values over the fence to the non-empty side for our use. This is a pretty big step, but if we stop there, the resulting system is still painfully limited, and process analysis only works on local variables and parameters.

To keep Dart as flexible as it was before it had air safety, and to some extent surpass it, we’ve brought in some other useful new features.

More intelligent space judgment method

Dart air escape operator? Relative to air safety seems to be a cut – old. According to the semantics of the runtime, if the receiver is NULL, the property access on the right is skipped and the expression is treated as NULL.

// Empty security:
String notAString = null;
print(notAString? .length);Copy the code

This code prints “NULL” instead of throwing an exception. The air-avoidance operator is a great tool to make nullable types available in Dart. Although we can’t let you call methods on nullable types, we can let you call them using air escape operators. The air Security version of the program looks like this:

// Use empty security:
String? notAString = null;
print(notAString? .length);Copy the code

As before, it works fine.

However, if you’ve ever used the air escape operator in Dart, you’ve probably experienced the annoying operation of chain method calls. Suppose you need to determine whether the length of a potentially empty string is even (this may not be a practical problem, but read on) :

// Use empty security:
String? notAString = null;
print(notAString? .length.isEven);Copy the code

Even if it works, right? It will still throw an exception at run time. The problem here is that.iseven’s receiver is left of the entire notAString? The result of the.length expression. This expression is considered null, so we get an empty reference error when we try to call.iseven. If you have used Dart? ., you may have learned the tedious method of adding the air escape operator to every attribute or method chain call after using it once.

String? notAString = null;
print(notAString? .length? .isEven);Copy the code

This is annoying, but even more deadly, it disrupts access to important information. Take a look at this:

// Use empty security:
showGizmo(Thing? thing) {
  print(thing? .doohickey? .gizmo); }Copy the code

Here we want to ask you a question: Does retrieving doohickey in Thing return NULL? It looks like it will return NULL because you used it after the call? . But maybe a second one? Just to handle the case where thing is null, not the result of doohickey. You can’t draw a direct conclusion.

To solve this problem, we borrowed a clever approach from C#’s design of the same functionality. When you use the air escape operator in a chained method call, if the receiver is judged to be null, the rest of the entire chain call is truncated and skipped. This means that if the return value of doohickey is a nullable type, you should write:

// Use empty security:
showGizmo(Thing? thing) {
  print(thing? .doohickey.gizmo); }Copy the code

In fact, if you don’t get rid of the second one? You will see a warning that this code is unnecessary. So if you see code like this:

// Use empty security:
showGizmo(Thing? thing) {
  print(thing? .doohickey? .gizmo); }Copy the code

You’ll immediately know that the return type of Doohickey itself is nullable. Each one? . Corresponds to a unique code path that allows NULL to be passed along the chain call. This makes the air escape operator in chained method calls more concise and precise.

In the meantime, we’ve also added some other air-avoidance operators here:

// Use empty security:

// Skip the cascade operator:receiver? . method();// Avoid index operator:receiver? [index];Copy the code

There is no null judgment function call operator yet, but you can write it like this:

// You can write this with or without null security:function? .call(arg1, arg2);Copy the code

Null-value assertion operator

Using process analysis, it is safe to move nullable variables to the non-empty side. You can call methods on previously nullable variables while still enjoying the security and performance benefits of non-null types.

However, many effective ways of using nullable types do not prove their safety to static analysis. Such as:

// Void security is used incorrectly:
class HttpResponse {
  final int code;
  final String? error;

  HttpResponse.ok() : code = 200;
  HttpResponse.notFound()
      : code = 404,
        error = 'Not found';

  String toString() {
    if (code == 200) return 'OK';
    return 'ERROR $code ${error.toUpperCase()}'; }}Copy the code

If you try to run this code, you will see a compilation error pointing to the toUpperCase() call. The error attribute is nullable and has no value when the result is returned successfully. By looking closely at the class, we can see that we never access error when the message is empty. But to understand this behavior, you must understand the relationship between the value of code and the nullability of error. The type checker does not see this connection.

In other words, as human maintainers of our code, we know that when error is used, its value will not be null and we need to assert it. Normally you can assert a type by using an AS transform, and here you can do the same:

// Use empty security:
String toString() {
  if (code == 200) return 'OK';
  return 'ERROR $code ${(error as String).toUpperCase()}';
}
Copy the code

An exception is thrown if, at run time, an unconverted error occurs when converting error to a non-empty String. If the conversion succeeds, a non-empty string is returned to us, allowing us to make the method call.

The frequent occurrence of “nullability free transformations” prompted us to introduce a new, snappy syntax. An exclamation mark as a suffix (!) Causes the expression on the left to be converted to its corresponding non-null type. So the above function is equivalent to:

// Use empty security:
String toString() {
  if (code == 200) return 'OK';
  return 'ERROR $code ${error! .toUpperCase()}';
}
Copy the code

This one-character “key operator” comes in handy when the original type is cumbersome. If only to convert one type to is not empty, you need to write a similar to the as Map < TransactionProviderFactory, List < Set < ResponseFilter > > > this code, will make the process become very annoying.

Of course, as with all other transformations, use! Some of the static security will be lost. These transformations must occur at run time to ensure that the code is sound and that there is a chance that they will fail and throw exceptions. But you have complete control over where these transformations are used, and you can see them directly from the code.

Lazy loading of variables

For top-level variables and fields, type checkers often cannot prove that they are safe. Here’s an example:

// Void security is used incorrectly:
class Coffee {
  String _temperature;

  void heat() { _temperature = 'hot'; }
  void chill() { _temperature = 'iced'; }

  String serve() => _temperature + ' coffee';
}

main() {
  var coffee = Coffee();
  coffee.heat();
  coffee.serve();
}
Copy the code

In this case, the heat() method is called before serve(). This means that _temperature is initialized to a non-null value before it is used. This is not feasible for static analysis. (The code might actually work in a case similar to the example, but in general it’s hard to keep track of the state of every instance.)

Because the type checker cannot analyze the purpose of fields and top-level variables, it follows a relatively conservative rule that non-nullable fields must be initialized at declaration time (or in the constructor’s list of initialized fields). So in this case, Dart will prompt a compilation error on this class.

To solve this problem, you can declare it nullable and then use the null assertion operator:

// Use empty security:
class Coffee {
  String? _temperature;

  void heat() { _temperature = 'hot'; }
  void chill() { _temperature = 'iced'; }

  String serve() => _temperature! + ' coffee';
}
Copy the code

Now, the code really does work. But it confuses the class’s maintainers. Making _temperature nullable implies that NULL is a useful value for a field. But it’s actually the opposite of what you’re trying to do. The _temperature field is never observed when null.

To handle common behavior like delayed initialization, we added a modifier: late. You can use it like this:

// Use empty security:
class Coffee {
  late String _temperature;

  void heat() { _temperature = 'hot'; }
  void chill() { _temperature = 'iced'; }

  String serve() => _temperature + ' coffee';
}
Copy the code

Notice here that the _temperature field is a non-empty type, but it is not initialized. Also, there is no explicit null assertion when used. Although there are several interpretations of the semantics of the late application, here it should be: The late modifier is “constrain variables at run time, not compile time.” This makes the word late approximately equal to when a constraint on a variable is enforced.

In the current scenario, the field is not necessarily initialized; each time it is read, a runtime check is inserted to ensure that it has been assigned. If no value is assigned, an exception is thrown. Adding String to a variable says, “My value is definitely a String,” while adding the late modifier means, “Check for truth every time you run it.”

In some ways, the late modifier is better than? Even more amazing, because any call to this field is likely to fail, and there is no text at the scene of the failed incident.

In return, it is more statically secure than nullable types. Because this field is now non-null, assigning it a null or nullable String at compile time would cause an error. While the late modifier lets you delay initialization, it still prevents you from treating variables as nullable types.

Lazy initialization

The late modifier also has some special capabilities. It may sound paradoxical, but you can use late on a field that contains the initialization:

// Use empty security:
class Weather {
  late int _temperature = _readThermometer();
}
Copy the code

When you declare this, you delay initialization. Instance construction is deferred until the field is first accessed, rather than initialized at instance construction time. In other words, it makes fields initialized exactly the same as top-level variables and static fields. This can be useful when initialization expressions are performance-intensive and may not be needed.

Deferred initialization gives you even more convenience when you use late on instance fields. Typically, the initialization content of the instance field is not accessible to this because you cannot access the new instance object until all the initialization methods are complete. However, using late makes this condition no longer true, so you can access this, call methods, and access instance fields.

The final value of the delay

You can also use late with final:

// Use empty security:
class Coffee {
  late final String _temperature;

  void heat() { _temperature = 'hot'; }
  void chill() { _temperature = 'iced'; }

  String serve() => _temperature + ' coffee';
}
Copy the code

Unlike regular final fields, you do not need to initialize them when they are declared or constructed. You can load it later somewhere in the run. But you can only assign to it once, and it will be validated at run time. If you try to assign it multiple times, such as when heat() and chill() are both called, the second assignment throws an exception. This is a good way to determine the state of the field, which will eventually be initialized and cannot be changed after initialization.

In other words, the new late modifier, combined with other variable modifiers for Dart, already implements many of the features of lateinit in Kotlin and lazy in Swift. If you need to add some lazy initialization to local variables, you can also use it on local variables.

Required named parameters

To ensure that you never see a null value for an argument of a non-null type, the type checker requires that all optional arguments be either a nullable type or contain a default value. What if you need a nullable named parameter that does not contain a default value? This means that you require the caller to pass something to it every time. In other words, you need a non-optional named parameter.

This table visually shows the Dart parameters:

Necessary optional + -- -- -- -- -- -- -- -- -- -- -- - + -- -- -- -- -- -- -- -- -- -- -- -- + location parameter (int x) | | f f ((int x)) | + -- -- -- -- -- -- -- -- -- -- -- - + -- -- -- -- -- -- -- -- -- -- -- -- + | named parameters??????? | f({int x}) | +------------+------------+Copy the code

Why Dart has long supported only three parameter types, rather than named + required combinations, remains a mystery. With the introduction of empty security, we added the missing parameter type. Now you just need to place required before the parameter to declare a required named parameter:

// Use empty security:
function({int? a, required int? b, int? c, required int? d}) {}
Copy the code

All arguments here must be passed by name. Parameters A and c are optional and can be omitted. The arguments b and d are required and must be passed when called. Note here that being required is independent of being nullable. You can write out required named parameters for nullable types and optional named parameters for non-nullable types if they contain default values.

Air-safe or not, this is another feature that makes Dart even better. It makes the language seem more complete.

Dance with nullable fields

The new features deal with a lot of common behavior patterns and make much of null handling less painful. Even so, in our experience, dealing with nullable fields can be difficult. In cases where you can use late and non-null types, this is pretty safe. However, in many scenarios, you still need to check if the field has a value, and in those cases, the field can be nullable and null can be observed.

Here’s the code you might think you could write:

// Void security is used incorrectly:
class Coffee {
  String? _temperature;

  void heat() { _temperature = 'hot'; }
  void chill() { _temperature = 'iced'; }

  void checkTemp() {
    if(_temperature ! =null) {
      print('Ready to serve ' + _temperature + '! '); }}String serve() => _temperature! + ' coffee';
}
Copy the code

In checkTemp(), we check if _temperature is null. If it’s not empty, we access it and call + on it. Unfortunately, this is not allowed. Type promotion based on process analysis does not apply to fields because static analysis cannot prove that the value of the field did not change after you judged it and before it was used. (In some extreme scenarios, the field itself may be overridden by the getter of the subclass to return NULL on the second invocation.)

Since code health is also a metric we care about, the field type will not be promoted and the above method will not compile. It’s actually not very comfortable. In simple cases like this, the best way to do this is to add! . It may seem redundant, but the current Dart requires this operation.

Another way to resolve this situation is to copy the field as a local variable and then use it:

// Use empty security:
void checkTemp() {
  var temperature = _temperature;
  if(temperature ! =null) {
    print('Ready to serve ' + temperature + '! '); }}Copy the code

For local variables, type promotion works, so it works fine. If you need to change its value, remember to store it back to the original field, not just update your local variable.

Nullability and generics

Like today’s dominant statically typed languages, Dart has generic classes and generic methods. They can be counterintuitive in how they interact with cavitation, but they make sense once you think through the underlying design intent. First, “Is this type nullable?” It is no longer a simple question of right and wrong. Let’s consider the following situation:

// Use empty security:
class Box<T> {
  final T object;
  Box(this.object);
}

main() {
  Box<String> ('a string');
  Box<int?> (null);
}
Copy the code

In the definition of Box, is T a nullable or non-nullable type? As you can see, it can be instantiated by either type. The answer is: T is a potentially nullable type. In the body of a generic class or method, a potentially nullable type contains all the limitations of nullable and non-nullable types.

The former means that no methods can be called other than a few defined on Object. The latter means that any fields or variables of this type need to be initialized before they can be used. This can make type parameters very difficult to deal with.

In fact, some patterns are already doing this. For example, when a class like a collection is instantiated, the type parameters can be of any type. You just need to handle type-specific constraints in an appropriate way when using instances. In most scenarios like the example here, this means that whenever you need to use the value of the type of the type parameter, you can ensure that you have access to that value. Fortunately, collections like classes rarely call methods directly on their elements.

You can make type arguments nullable when you do not need to access values:

// Use empty security:
class Box<T> {
  T? object;
  Box.empty();
  Box.full(this.object);
}
Copy the code

Note that the object declaration? . This field is now an explicit nullable type, so it can be uninitialized.

When you change a type parameter to a nullable type like here, you may need to force it to a non-nullable type. The correct way to do this is to explicitly use as T for the conversion instead of using! Operators.

// Use empty security:
class Box<T> {
  final T? object;
  Box.empty();
  Box.full(this.object);

  T unbox() => object as T;
}
Copy the code

If the value is null, use! The operator must throw an exception. But if the type parameter has been declared as a nullable type, then null is a fully valid value for T:

// Use empty security:
main() {
  var box = Box<int?>.full(null);
  print(box.unbox());
}
Copy the code

This code works thanks entirely to the use of AS T, whereas if you use! It throws an exception.

Other generics also have type constraints that limit the types of available type parameters:

// Use empty security:
class Interval<T extends num> {
  T min, max;

  Interval(this.min, this.max);

  bool get isEmpty => max <= min;
}
Copy the code

If the type constraint is non-empty, then the type parameter is also non-empty. This means that you have some limitations on non-null types, that is, you have to initialize fields and variables. The class in the example must have a constructor to initialize the field.

As a bonus, you can call any method whose type parameter inherits from its type constraint. Of course, non-null type constraints prevent consumers from instantiating generics with nullable type parameters. This is also a reasonable limitation for most categories.

You can also use nullable type constraints:

// Use empty security:
class Interval<T extends num? >{
  T min, max;

  bool get isEmpty {
    var localMin = min;
    var localMax = max;

    // If there is no maximum or minimum limit, there is no limit to the time interval.
    if (localMin == null || localMax == null) return false;
    returnlocalMax <= localMin; }}Copy the code

This means that in the body of the class, you have the flexibility to treat type parameters as nullable types. Notice that we don’t have constructors this time, but that’s fine too. The field will be implicitly initialized to NULL. You can declare an uninitialized variable as the type of a type parameter.

But you’re also limited by nullability, and you can’t call any of the variables’ methods unless you handle the nullability state first. In the example here, we copied the fields to local variables and checked if they were null, so the process analysis promoted them to non-null before we called <=.

Note that the nullable type constraint does not prevent users from instantiating classes with non-null types. A nullable type constraint means that a type parameter can be null, not must be. (In fact, if you don’t write an extends statement, the default type constraint for a type parameter is nullable Object? ). There is no way to declare a required nullable type parameter. If you want to ensure that type parameters must be nullable, you can use T? .

Core library changes

We have a few other minor tweaks to the language. For example, a catch that does not use on now returns Object instead of Dynamic by default. At the same time, new flow analysis is used for conditional penetration analysis in switch statements.

The remaining important changes are in the core library. Before we embarked on this air security adventure, we were worried that we might have to make massive and disruptive changes to the existing language system in order to make the core library air safe. The results were not as dire as they might have been. For the most part, the migration went smoothly, although there were some significant changes. Most core libraries either don’t accept NULL and use non-empty types naturally, or accept NULL and gracefully handle nullable types.

But here are some more important details:

Map index operators are nullable

This isn’t really a change, but you should know about it. The [] operator of the Map class returns null if the key does not exist. This implies that the return type of the operator must be nullable V? Not V.

We could have thrown an exception if the key value was not present and changed the return type to a non-null type that is easier to use. However, confirming the presence of a key by null through the index operator is a very common operation, and in our analysis, about half of the operations were used for this purpose. Breaking this code directly destroys the Dart ecosystem.

In fact, the runtime behaves the same, so the return type must be nullable. This means that you cannot use the results of the query immediately when a Map query is performed:

// Void security is used incorrectly:
var map = {'key': 'value'};
print(map['key'].length); // Error.
Copy the code

This code throws a compilation exception at the.length call because you are trying to call a nullable string. In cases where you already know the key exists, you can give the type checker one! :

// Use empty security:
var map = {'key': 'value'};
print(map['key']! .length);// OK.
Copy the code

We considered adding another method to Map that would help you do this: look for key values, throw an exception if none is found, and return a non-null value otherwise. But what should we call it? No name is better than one! Come short, and no method name will be better than one! The call semantics are clearer. So, the way to find a known element in a Map is []! . I’m sure you’ll get used to it.

Remove the unnamed constructs of List

The unnamed constructor for List creates a List of a given size, but does not initialize any elements. If you create a list of non-empty types and then access one of the elements, this is a huge vulnerability.

To avoid this, we remove the constructor entirely. In null-safe code, calling List() throws an error, even for a nullable type. This may sound scary, but in real development, most of the code creates lists through literals, list.filled (), list.generate (), or some other collection transformation. For extreme cases where you need to create an empty List of a type, we add the list.empty () construct.

Creating a completely uninitialized list in Dart always didn’t feel right, it did, and it still does. If your code is affected by this change, you can always generate a list by other means.

You cannot set a larger length for a non-empty list

Less well known is the fact that the length getter for List also has a corresponding setter. You can truncate the list by setting it to a shorter length. You can also set the list to a longer length to populate it with uninitialized elements.

If you do this to a non-empty list, you run afoul of null-safe soundness when accessing uninitialized elements. To prevent accidents, now calling the Length setter on an array call of a non-empty type and preparing to set a longer length will throw an exception at run time. You can still truncate any type of list, and you can also populate a list of nullable types.

If you customize the type of the list, such as inheriting ListBase or mixing it with ListMixin, this change can have a significant impact. Both types provide insert() implementations that provide space for inserted elements by setting the length. Doing so can be wrong in air safety, so we changed their insert() implementation to add(). Your custom list should now inherit the add() method.

Iterator.current cannot be accessed before or after iteration

Iterable is a mutable “cursor” class used to iterate over elements of Iterable type. Before accessing any element, you need to call moveNext() to jump to the first element. When the method returns false, it means that you have reached the end and there are no more elements.

Previously, calling current returned NULL before the first call to moveNext() or after the iteration. With empty safety, the return type of current is required to be E? Instead of E. Such a return type means that at run time, all elements are checked for NULL before being accessed.

Given that few people currently access the current element in the wrong way, these checks are useless. So we set the return type of current to E. Since there may be a value of the corresponding type before and after the iteration, we leave the behavior of the iterator undefined when you call it when you shouldn’t. Most implementations of Iterator throw a StateError exception.

conclusion

This is a very detailed air security journey through all the language and library changes. It’s a lot, but it’s also a very big language change. More importantly, we hope Dart still feels good and consistent. So not only does the type system need to change, but some of the usability features change around it at the same time. We don’t want air safety just to get bolted down to bad features.

The key points you need to understand are:

  • The type is non-empty by default and can be added?Become void.
  • Optional arguments must be nullable or contain default values. You can userequiredTo build a non-optional named parameter. Non-empty global variables and static fields must be initialized at declaration time. The non-empty fields of the instance must be initialized before the constructor begins execution.
  • If the receiver isnull, all chained method calls after its escape operator are truncated. We introduced a new null judgment cascade operator (?..) and index operators (? []). Postfix null assertion “emphasis” operator (!Nullable operation objects can be converted to their corresponding non-null types.
  • New process analysis allows you to more safely convert nullable local variables and parameters to usable non-null types. It also has smarter rules for type promotion, missed returns, unreachable code, and variable initialization.
  • lateModifiers, at the cost of checking each time at run time, allow you to use non-null types and where they would otherwise not be availablefinal. It also provides support for lazy initialization of fields.
  • ListClasses are no longer allowed to contain uninitialized elements.

Finally, when you absorb everything from this article and take your code truly to the world of null-safety, you have a robust program that the compiler can optimize, and you can see in your code where every run-time thing could go wrong. I hope all your efforts are worth it.

Thank you

This article was written by Bob Nystrom, a member of the Google Dart team, and published in the Dart documentation. The translation was initiated and completed by Alex, a community member. The success of this article was due to the hard work of the following members:

  • Alex Is a member of the Community of Flutter.cn and the GitHub open source group of the Flutter project
  • Moderated: Xinlei Wang, member of the Flutter.cn community, Didi Chuxing Flutter engineer, And CaiJingLong and Demin from the community contributed to this article.
  • Technical discussion: Flutter Community Discord platform “Hackers – NNBD” channel

If you have any further discussion about this article, please feel free to contact us via the comment section or issue in the documentation warehouse. Thank you!