Generics
Dart originally came with generics—a facility of generic programming. We have to tell the static analyzer the permitted type of a collection so it can inform us at compile time if we insert a wrong type of object. As a result, programs become clearer and safer to use. We will discuss how to effectively use generics and minimize the complications associated with them.
Raw types
Dart supports arrays in the form of the List
class. Let's say you use a list to store data. The data that you put in the list depends on the context of your code. The list may contain different types of data at the same time, as shown in the following code:
// List of data List raw = [1, "Letter", {'test':'wrong'}]; // Ordinary item double item = 1.23; void main() { // Add the item to array raw.add(item); print(raw); }
In the preceding code, we assigned data of different types to the raw
list. When the code executes, we get the following result:
[1, Letter, {test: wrong}, 1.23]
So what's the problem with this code? There is no problem. In our code, we intentionally used the default raw
list class in order to store items of different types. But such situations are very rare. Usually, we keep data of a specific type in a list. How can we prevent inserting the wrong data type into the list? One way is to check the data type each time we read or write data to the list, as shown in the following code:
// Array of String data List parts = ['wheel', 'bumper', 'engine']; // Ordinary item double item = 1.23; void main() { if (item is String) { // Add the item to array parts.add(item); } print(parts); }
Now, from the following result, we can see that the code is safer and works as expected:
[wheel, bumper, engine]
The code becomes more complicated with those extra conditional statements. What should you do when you add the wrong type in the list and it throws exceptions? What if you forget to insert an extra conditional statement? This is where generics come to the fore.
Instead of writing a lot of type checks and class casts when manipulating a collection, we tell the static analyzer what type of object the list is allowed to contain. Here is the modified code, where we specify that parts
can only contain strings:
// Array of String data List<String> parts = ['wheel', 'bumper', 'engine']; // Ordinary item double item = 1.23; void main() { // Add the item to array parts.add(item); print(parts); }
Now, List
is a generic class with the String
parameter. Dart Editor invokes the static analyzer to check the types in the code for potential problems at compile time and alert us if we try to insert a wrong type of object in our collection, as shown in the following screenshot:
This helps us make the code clearer and safer because the static analyzer checks the type of the collection at compile time. The important point is that you shouldn't use raw types. As a bonus, we can use a whole bunch of shorthand methods to organize iteration through the list of items to cast safer. Bear in mind that the static analyzer only warns about potential problems and doesn't generate any errors.
Note
Dart checks the types of generic classes only in the check mode. Execution in the production mode or code compiled to JavaScript loses all the type information.
Using generics
Let's discuss how to make the transition to using generics in our code with some real-world examples. Assume that we have the following AssemblyLine
class:
part of assembly.room; // AssemblyLine. class AssemblyLine { // List of items on line. List _items = []; // Add [item] to line. add(item) { _items.add(item); } // Make operation on all items in line. make(operation) { _items.forEach((item) { operation(item); }); } }
Also, we have a set of different kinds of cars, as shown in the following code:
part of assembly.room; // Car abstract class Car { // Color String color; } // Passenger car class PassengerCar extends Car { String toString() => "Passenger Car"; } // Truck class Truck extends Car { String toString() => "Truck"; }
Finally, we have the following assembly.room
library with a main
method:
library assembly.room; part 'assembly_line.dart'; part 'car.dart'; operation(car) { print('Operate ${car}'); } main() { // Create passenger assembly line AssemblyLine passengerCarAssembly = new AssemblyLine(); // We can add passenger car passengerCarAssembly.add(new PassengerCar()); // We can occasionally add Truck as well passengerCarAssembly.add(new Truck()); // Operate passengerCarAssembly.make(operation); }
In the preceding example, we were able to add the occasional truck in the assembly line for passenger cars without any problem to get the following result:
Operate Passenger Car Operate Truck
This seems a bit far fetched since in real life, we can't assemble passenger cars and trucks in the same assembly line. So to make your solution safer, you need to make the AssemblyLine
type generic.
Generic types
In general, it's not difficult to make a type generic. Consider the following example of the AssemblyLine
class:
part of assembly.room; // AssemblyLine. class AssemblyLine <E extends Car> { // List of items on line. List<E> _items = []; // Add [item] to line. add(E item) { _items.insert(0, item); } // Make operation on all items in line. make(operation) { _items.forEach((E item) { operation(item); }); } }
In the preceding code, we added one type parameter, E
, in the declaration of the AssemblyLine
class. In this case, the type parameter requires the original one to be a subtype of Car
. This allows the AssemblyLine
implementation to take advantage of Car
without the need for casting a class. The type parameter E
is known as a bounded type parameter. Any changes to the assembly.room
library will look like this:
library assembly.room; part 'assembly_line.dart'; part 'car.dart'; operation(car) { print('Operate ${car}'); } main() { // Create passenger assembly line AssemblyLine<PassengerCar> passengerCarAssembly = new AssemblyLine<PassengerCar>(); // We can add passenger car passengerCarAssembly.add(new PassengerCar()); // We can occasionally add truck as well passengerCarAssembly.add(new Truck()); // Operate passengerCarAssembly.make(operation); }
The static analyzer alerts us at compile time if we try to insert the Truck
argument in the assembly line for passenger cars, as shown in the following screenshot:
After we fix the code in line 17, all looks good. Our assembly line is now safe. But if you look at the operation function, it is totally different for passenger cars than it is for trucks; this means that we must make the operation generic as well. The static analyzer doesn't show any warnings and, even worse, we cannot make the operation generic directly because Dart doesn't support generics for functions. But there is a solution.
Generic functions
Functions, like all other data types in Dart, are objects, and they have the data type Function
. In the following code, we will create an Operation
class as an implementation of Function
and then apply generics to it as usual:
part of assembly.room; // Operation for specific type of car class Operation<E extends Car> implements Function { // Operation name final String name; // Create new operation with [name] Operation(this.name); // We call our function here call(E car) { print('Make ${name} on ${car}'); } }
The gem in our class is the call
method. As Operation
implements Function
and has a call
method, we can pass an instance of our class as a function in the make
method of the assembly
line, as shown in the following code:
library assembly.room; part 'assembly.dart'; part 'car.dart'; part 'operation.dart'; main() { // Paint operation for passenger car Operation<PassengerCar> paint = new Operation<PassengerCar>("paint"); // Paint operation for Trucks Operation<Truck> paintTruck = new Operation<Truck>("paint"); // Create passenger assembly line Assembly<PassengerCar> passengerCarAssembly = new Assembly<PassengerCar>(); // We can add passenger car passengerCarAssembly.add(new PassengerCar()); // Operate only with passenger car passengerCarAssembly.make(paint); // Operate with mistake passengerCarAssembly.make(paintTruck); }
In the preceding code, we created the paint
operation to paint the passenger cars and the paintTruck
operation to paint trucks. Later, we created the passengerCarAssembly
line and added a new passenger car to the line via the add
method. We can run the paint
operation on the passenger car by calling the make
method of the passengerCarAssembly
line. Next, we intentionally made a mistake and tried to paint the truck on the assembly line for passenger cars, which resulted in the following runtime exception:
Make paint on Passenger Car Unhandled exception: type 'PassengerCar' is not a subtype of type 'Truck' of 'car'. #0 Operation.call (…/generics_operation.dart:10:10) #1 Assembly.make.<anonymous closure>(…/generics_assembly.dart:16:15) #2 List.forEach (dart:core-patch/growable_array.dart:240) #3 Assembly.make (…/generics_assembly.dart:15:18) #4 main (…/generics_assembly_and_operation_room.dart:20:28) …
This trick with the call
method of the Function
type helps you make all the aspects of your assembly line generic. We've seen how to make a class generic and function to make the code of our application safer and cleaner.
Note
The documentation generator automatically adds information about generics in the generated documentation pages.
To understand the differences between errors and exceptions, let's move on to the next topic.