Flutter (7): Introduction to Dart language

Time: Column:Mobile & Frontend views:262

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:

  1. 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.

  1. 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.