7.1 Variable Declaration
#1) var
Keyword
Similar to the var
in JavaScript, it can receive variables of any type. However, the biggest difference is that in Dart, once a var
variable is assigned a value, its type is fixed and cannot be changed, as shown below:
var t = "hi world"; // The following code will cause an error in Dart because the type of variable `t` is already determined as String. // Once the type is determined, it cannot be changed. t = 1000;
The code above would work fine in JavaScript, so front-end developers should take note. This difference exists because Dart is a strongly-typed language, where every variable has a specific type. In Dart, when a variable is declared with var
, Dart infers its type from the first assigned value during compilation. After compilation, the type is set. On the other hand, JavaScript is a weakly-typed scripting language where var
is just a way of declaring a variable without enforcing type constraints.
#2) dynamic
and Object
Object
is the root class of all objects in Dart. This means that all types in Dart are subclasses of Object
(including Function
and Null
). Therefore, any type of data can be assigned to an Object
declared variable. Both dynamic
and Object
declared variables can be assigned any object, and their type can change later, which is different from var
. For example:
dynamic t; Object x; t = "hi world"; x = 'Hello Object'; // The following code works fine t = 1000; x = 1000;
The difference between dynamic
and Object
is that for a variable declared with dynamic
, the compiler allows all possible combinations, while a variable declared with Object
can only use the properties and methods of Object
. Otherwise, the compiler will throw an error, as shown below:
dynamic a; Object b = ""; main() { a = ""; printLengths(); } printLengths() { // Works fine print(a.length); // Error: The getter 'length' is not defined for the class 'Object' print(b.length); }
The nature of dynamic
requires special attention when using it, as it can easily lead to runtime errors. For instance, the following code won't cause any issues at compile time but will fail at runtime:
print(a.xx); // 'a' is a string and has no "xx" property. No compile-time error, but runtime error will occur.
#3) final
and const
If you never intend to change a variable, use final
or const
instead of var
or a specific type. A final
variable can only be set once. The key difference between them is that a const
variable is a compile-time constant (its value is replaced during compilation), whereas a final
variable is initialized the first time it's used. The type can be omitted when using final
or const
, as shown below:
// The String type declaration can be omitted final str = "hi world"; // Equivalent to: final String str = "hi world"; const str1 = "hi world"; // Equivalent to: const String str1 = "hi world";
#4) Null Safety
In Dart, everything is an object, which means that if we define a number and use it before initialization, without a certain check mechanism, no error would be thrown. For example:
test() { int i; print(i * 8); }
Before Dart introduced null safety, the above code wouldn’t show any errors before execution but would trigger a runtime error since i
would be null
. With null safety, when defining variables, we can specify whether the variable can be null or not.
int i = 8; // By default, non-nullable, must be initialized when declared. int? j; // Nullable type. For nullable variables, we must check for null before using.
If we expect a variable to be non-null but cannot determine its initial value at declaration, we can use the late
keyword, indicating it will be initialized later but must be initialized before actual use, or an error will occur:
late int k; k = 9;
If a variable is defined as nullable, and in some cases, even after assigning it a value, the preprocessor may still not recognize it. In such cases, we need to explicitly tell the preprocessor that it is no longer null
by using the "!" symbol, like so:
class Test { int? i; Function? fun; say() { if (i != null) { print(i! * 8); // Since null check is done, i cannot be null at this point. Otherwise, IDE will show an error. } if (fun != null) { fun!(); // Same as above. } } }
Additionally, if the function variable is nullable, you can use syntactic sugar to call it:
fun?.call(); // fun will be called only if it is not null.
7.2 Functions
Dart is a truly object-oriented language, meaning that even functions are objects, and they have a type of Function
. This means that functions can be assigned to variables or passed as arguments to other functions, which is a typical feature of functional programming.
#1. Function Declaration
bool isNoble(int atomicNumber) { return _nobleGases[atomicNumber] != null; }
In Dart, if a function does not explicitly declare a return type, it is treated as dynamic
. Note that function return values do not support type inference:
typedef bool CALLBACK(); // No return type specified, defaults to `dynamic`, not `bool` isNoble(int atomicNumber) { return _nobleGases[atomicNumber] != null; } void test(CALLBACK cb) { print(cb()); } // Error: isNoble is not of type `bool` test(isNoble);
For functions that contain only a single expression, you can use shorthand syntax:
bool isNoble(int atomicNumber) => true;
#2. Functions as Variables
var say = (str) { print(str); }; say("hi world");
#3. Functions as Parameters
You can also pass functions as arguments to other functions:
// Define a function `execute` whose parameter is of type function void execute(var callback) { callback(); // Execute the passed function } // Call `execute`, passing an arrow function as an argument execute(() => print("xxx"));
#1) Optional Positional Parameters
You can wrap a set of function parameters with []
to mark them as optional positional parameters, placing them at the end of the parameter list:
String say(String from, String msg, [String? device]) { var result = '$from says $msg'; if (device != null) { result = '$result with a $device'; } return result; }
Here is an example of calling this function without optional parameters:
say('Bob', 'Howdy'); // Result: Bob says Howdy
Here is an example of calling this function with the third parameter:
say('Bob', 'Howdy', 'smoke signal'); // Result: Bob says Howdy with a smoke signal
#2) Optional Named Parameters
When defining a function, use {param1, param2, ...}
at the end of the parameter list to specify named parameters. For example:
// Set the [bold] and [hidden] flags void enableFlags({bool bold, bool hidden}) { // ... }
When calling the function, you can use the named parameters like this: paramName: value
.
enableFlags(bold: true, hidden: false);
Optional named parameters are used frequently in Flutter. Note that you cannot use optional positional parameters and optional named parameters at the same time.
7.3 Mixin
Dart does not support multiple inheritance, but it does support mixins. In simple terms, a mixin allows you to "compose" multiple classes. Let's understand this through an example.
We define a Person
class that implements the functions for eating, speaking, walking, and coding, and simultaneously define a Dog
class that implements the functions for eating and walking:
class Person { say() { print('say'); } } mixin Eat { eat() { print('eat'); } } mixin Walk { walk() { print('walk'); } } mixin Code { code() { print('key'); } } class Dog with Eat, Walk {} class Man extends Person with Eat, Walk, Code {}
In this example, we defined several mixins and then combined them into different classes using the with
keyword. One point to note is that if multiple mixins contain methods with the same name, the method from the last mixin will be used by default. Methods in a mixin can call methods from previous mixins or classes using the super
keyword. Here, we only introduce the basic features of mixins; readers can find more detailed information about mixins through other resources.
7.4 Asynchronous Support
Dart's library has many functions that return Future
or Stream
objects. These functions are known as asynchronous functions: they will return only after setting up some time-consuming operations, such as IO operations, rather than waiting for the operation to complete.
The async
and await
keywords support asynchronous programming, allowing you to write asynchronous code that looks similar to synchronous code.
#1. Future
Future
is very similar to Promise
in JavaScript, representing the eventual completion (or failure) of an asynchronous operation and its resulting value. In simple terms, it is used to handle asynchronous operations; if the asynchronous processing succeeds, it executes the success operation, and if it fails, it captures the error or halts subsequent operations. A Future
corresponds to a single result, which can either be successful or a failure.
Due to its various functionalities, we will only introduce its commonly used APIs and features here. Also, keep in mind that all API return values of Future
are still Future
objects, making chaining easy.
#1) Future.then
For convenience, in this example, we create a delayed task using Future.delayed
(in a real scenario, this would be a real time-consuming task, such as a network request), which returns the result string "hi world!" after 2 seconds. We then receive the asynchronous result in then
and print it, as shown below:
Future.delayed(Duration(seconds: 2), () { return "hi world!"; }).then((data) { print(data); });
#2) Future.catchError
If an error occurs in the asynchronous task, we can catch the error in catchError
. We modify the above example as follows:
Future.delayed(Duration(seconds: 2), () { // return "hi world!"; throw AssertionError("Error"); }).then((data) { // If successful, it goes here print("success"); }).catchError((e) { // If failed, it goes here print(e); });
In this example, we throw an exception in the asynchronous task, and the then
callback will not be executed; instead, the catchError
callback will be called. However, catchError
is not the only way to catch errors; the then
method also has an optional onError
parameter that we can use to capture exceptions:
Future.delayed(Duration(seconds: 2), () { // return "hi world!"; throw AssertionError("Error"); }).then((data) { print("success"); }, onError: (e) { print(e); });
#3) Future.whenComplete
Sometimes, we encounter scenarios where we need to perform certain actions regardless of whether the asynchronous task succeeds or fails, such as popping up a loading dialog before a network request and closing it after the request completes. In such cases, there are two methods: the first is to close the dialog in then
or catch
, and the second is to use the whenComplete
callback of Future
. We modify the previous example as follows:
Future.delayed(Duration(seconds: 2), () { // return "hi world!"; throw AssertionError("Error"); }).then((data) { // If successful, it goes here print(data); }).catchError((e) { // If failed, it goes here print(e); }).whenComplete(() { // Goes here whether successful or failed });
#4) Future.wait
Sometimes, we need to wait for multiple asynchronous tasks to complete before proceeding with some operations. For example, we have an interface that needs to retrieve data from two network endpoints separately. Once successfully retrieved, we need to process the data from both interfaces before displaying it on the UI. The solution is Future.wait
, which accepts an array of Future
parameters. The success callback in then
will only trigger when all futures in the array complete successfully; if any future fails, it will trigger the error callback. Below, we simulate two asynchronous data retrieval tasks using Future.delayed
, and when both tasks are successful, we concatenate and print their results:
Future.wait([ // Returns result after 2 seconds Future.delayed(Duration(seconds: 2), () { return "hello"; }), // Returns result after 4 seconds Future.delayed(Duration(seconds: 4), () { return " world"; }) ]).then((results) { print(results[0] + results[1]); }).catchError((e) { print(e); });
When you execute the above code, after 4 seconds, you will see "hello world" in the console.
2. async/await
The functionality of async/await
in Dart is the same as in JavaScript: it serializes asynchronous tasks. If you are already familiar with the usage of async/await
in JavaScript, you can skip this section.
#1) Callback Hell
If there are many asynchronous logics in the code and a large number of asynchronous tasks depend on the results of other asynchronous tasks, you will inevitably encounter a situation where callbacks are nested within Future.then
. For example, consider a scenario where a user needs to log in, and upon successful login, they will receive a user ID. Then, using that user ID, they will request user information. After retrieving the user information, to facilitate usage, we need to cache it in the local file system. The code would look like this:
// First, define each asynchronous task Future<String> login(String userName, String pwd) { ... // User login }; Future<String> getUserInfo(String id) { ... // Get user information }; Future saveUserInfo(String userInfo) { ... // Save user information }; // Next, execute the entire task flow: login("alice", "******").then((id) { // Upon successful login, get user info using the ID getUserInfo(id).then((userInfo) { // Save the retrieved user information saveUserInfo(userInfo).then(() { // After saving user information, proceed with other operations ... }); }); });
You can feel that if there are many asynchronous dependencies in the business logic, it leads to this nested callback situation. Excessive nesting reduces code readability and increases the likelihood of errors, making it very difficult to maintain. This issue is vividly referred to as Callback Hell. The callback hell problem was particularly prominent in JavaScript and is one of the most criticized aspects of it. However, after the ECMAScript standard was released, this problem was effectively addressed, thanks to two powerful tools introduced in ECMAScript 6: Promise
and async/await
. Dart nearly completely translates these two from JavaScript: Future
is equivalent to Promise
, and async/await
has kept the same name. Next, let’s see how we can eliminate the nested problems in the above example using Future
and async/await
.
#2) Eliminating Callback Hell
There are two main ways to eliminate Callback Hell:
Using Future to Eliminate Callback Hell
login("alice", "******").then((id) { return getUserInfo(id); }).then((userInfo) { return saveUserInfo(userInfo); }).then((e) { // Execute subsequent operations }).catchError((e) { // Error handling print(e); });
As mentioned above, "All API return values of Future are still Future objects, allowing for convenient chaining." If what is returned in then
is a Future
, that future will execute, and once it finishes, it will trigger the next then
callback, thus avoiding layers of nesting.
Using async/await to Eliminate Callback Hell
While returning a Future
from then
helps avoid nesting, there is still one layer of callbacks. Is there a way to execute asynchronous tasks like synchronous code without using callbacks? The answer is yes; this is where async/await
comes in. Let’s look at the code directly, followed by an explanation:
task() async { try{ String id = await login("alice","******"); String userInfo = await getUserInfo(id); await saveUserInfo(userInfo); //执行接下来的操作 } catch(e){ //错误处理 print(e); } }
async
is used to indicate that a function is asynchronous. The defined function will return a Future
object, and you can use the then
method to add a callback.
await
is followed by a Future
, which represents waiting for the completion of that asynchronous task. Only after the task completes will the execution proceed; await
must appear within an async
function.
As you can see, we have used async/await
to express an asynchronous flow as if it were synchronous code.
In fact, whether in JavaScript or Dart, async/await
is just syntactic sugar. The compiler or interpreter will eventually translate it into a chain of Promise
(or Future
) calls.
7.5 Stream
A Stream
is also used to handle asynchronous events and data. However, unlike Future
, it can receive multiple results (success or failure) from asynchronous operations. In other words, during the execution of an asynchronous task, it can repeatedly trigger success or failure events to pass result data or error exceptions. Streams are often used in scenarios involving asynchronous tasks that read data multiple times, such as downloading network content, reading or writing files, etc. Here's an example:
Stream.fromFutures([ // Returns result after 1 second Future.delayed(Duration(seconds: 1), () { return "hello 1"; }), // Throws an exception Future.delayed(Duration(seconds: 2),(){ throw AssertionError("Error"); }), // Returns result after 3 seconds Future.delayed(Duration(seconds: 3), () { return "hello 3"; }) ]).listen((data){ print(data); }, onError: (e){ print(e.message); }, onDone: (){ });
The output of the above code will be:
I/flutter (17666): hello 1 I/flutter (17666): Error I/flutter (17666): hello 3
The code is straightforward, so I won’t go into more detail.
Thought Exercise: Since Stream
can receive multiple events, could we implement a publisher-subscriber model event bus using Stream
?
7.6 Comparison between Dart, Java, and JavaScript
Based on the previous introduction, you should now have a preliminary understanding of Dart. Since I also use Java and JavaScript, I’d like to share my thoughts on Dart compared to these languages.
The reason for comparing Dart with Java and JavaScript is that they are typical representatives of strong-typed and weak-typed languages, respectively. Additionally, Dart's syntax draws heavily from both Java and JavaScript.
1. Dart vs Java
Objectively speaking, Dart is more expressive than Java in terms of syntax. In the VM layer, Dart's VM has undergone multiple optimizations in memory management and throughput. I haven't found specific performance comparison data, but from my experience, as long as Dart becomes popular, there’s no need to worry about the VM's performance. After all, Google has a lot of technical experience in Go, JavaScript (V8), and Dalvik (the Java VM on Android). It’s worth noting that Dart, when used in Flutter, can achieve garbage collection (GC) times of under 10ms. So, compared to Java, Dart's competitiveness won't necessarily lie in performance.
In terms of syntax, Dart is much more expressive than Java, especially in its support for functional programming, which is far superior to Java (which currently only supports Lambda expressions). Dart’s real shortcoming, however, is its ecosystem. Still, with the growing popularity of Flutter, I believe the Dart ecosystem will catch up. For Dart, the key factor is time.
2. Dart vs JavaScript
JavaScript has long been criticized for being a "weakly-typed" language, which is why TypeScript (a superset of JavaScript that adds static types) has a market. Of all the scripting languages I’ve used (including Python and PHP), JavaScript undoubtedly has the best support for dynamic features. For instance, in JavaScript, you can dynamically add properties to any object at any time. For JavaScript experts, this is a powerful tool.
However, everything has two sides. JavaScript’s powerful dynamic features can also be a double-edged sword. You’ll often hear complaints that this flexibility makes the code unpredictable and difficult to maintain, as it’s hard to prevent unwanted modifications. Some developers, uncertain about their own or others’ code, want their code to be more controlled and seek a static type-checking system to reduce errors.
For this reason, Dart has almost abandoned the dynamic nature of scripting languages in Flutter. For example, Dart doesn’t support reflection or dynamic function creation. Moreover, starting from Dart 2.0, strict type-checking (Strong Mode) is enforced, and the previous checked mode and optional types are fading out. Thus, in terms of type safety, Dart is similar to TypeScript and CoffeeScript.
In terms of dynamic capabilities, Dart doesn't offer significant advantages. However, considering that Dart can be used for server-side scripting, app development, and web development, it clearly has its own strengths.