Today we'll learn about generics – a core technology that solves the problem of "reusing generic logic" while maintaining "type safety".
I. Why Do We Need Generics? – From "Duplicate Code" to "Generic Logic"
Suppose we need to implement a "box" class to store different types of data (like numbers or strings). Without generics, we might write code like this:
// Box for storing integers
class IntBox {
int value;
IntBox(this.value);
int getValue() => value;
}
// Box for storing strings
class StringBox {
String value;
StringBox(this.value);
String getValue() => value;
}
void main() {
IntBox intBox = IntBox(10);
StringBox stringBox = StringBox("Dart");
print(intBox.getValue()); // Output: 10
print(stringBox.getValue()); // Output: Dart
}
The problem with this approach is obvious: the logic is identical, but we have to implement it repeatedly for each type. If we need to support more types (like bool or List), the code becomes unnecessarily bloated.
Even worse, if we use dynamic to create a "generic" box, we lose type safety:
// "Generic" box using dynamic (loses type safety)
class DynamicBox {
dynamic value;
DynamicBox(this.value);
dynamic getValue() => value;
}
void main() {
DynamicBox box = DynamicBox(10);
// No compile-time error, but crashes at runtime (integers don't have length)
print(box.getValue().length); // Runtime error: NoSuchMethodError
}
Generics were created to solve both problems simultaneously: enabling code reuse while maintaining type safety.
II. Generics Basics: Generic Classes and Type Parameters
The core idea of generics is: when defining classes or functions, we don't specify concrete types, but use a "placeholder" to represent types, and specify the concrete type when using them. This "placeholder" is called a type parameter.
1. Generic Classes
Taking our "box" example, a generic class can be implemented once to support all types:
// Generic class: declare type parameter T with <T> (T is a placeholder, can be custom named)
class Box<T> {
T value; // Use T as the type for value
Box(this.value); // Constructor parameter type is T
T getValue() => value; // Return type is T
}
void main() {
// Specify concrete type when using: int
Box<int> intBox = Box<int>(10);
int num = intBox.getValue(); // Type-safe: return value is definitely int
print(num); // Output: 10
// Specify concrete type when using: String
Box<String> stringBox = Box<String>("Dart");
String str = stringBox
.getValue(); // Type-safe: return value is definitely String
print(str); // Output: Dart
// Type error: compile-time error (can't store String in int box)
// Box<int> errorBox = Box<int>("error"); // Compile error
}
Advantages of generic classes:
- Code reuse: One implementation supports all types
- Type safety: Compile-time type checking prevents runtime errors
- Type inference: Dart automatically infers type parameters from values, simplifying code:
Box<int> intBox = Box(10); // Omit <int> - compiler infers it
Box<String> stringBox = Box("Dart"); // Omit <String>
2. Generic Functions
Not only classes can be generic – functions and methods can also implement generic logic through generics.
// Generic function: declare type parameter with <T>, use T for parameters and return type
T getFirstElement<T>(List<T> list) {
if (list.isEmpty) {
throw Exception("List is empty");
}
return list[0]; // Return first element with type T
}
void main() {
List<int> numbers = [1, 2, 3];
int firstNum = getFirstElement(numbers); // Automatically infers T as int
print(firstNum); // Output: 1
List<String> fruits = ["apple", "banana"];
String firstFruit = getFirstElement(
fruits,
); // Automatically infers T as String
print(firstFruit); // Output: apple
}
Generic functions allow us to write common algorithms (like sorting, filtering, or transforming) for different input types while maintaining type safety.
III. Generic Constraints: Restricting Type Parameter Range
By default, generic type parameters can be any type. But sometimes we need to restrict type parameters to specific types or their subclasses – these are called generic constraints.
Use the extends keyword to specify generic constraints:
1. Constraining to Specific Types
// Define a class with a name property
class HasName {
String name;
HasName(this.name);
}
// Generic class constraint: T must be HasName or its subclass
class NameBox<T extends HasName> {
T value;
NameBox(this.value);
// Safely call T's name property (since T must be a HasName subclass)
String getValueName() => value.name;
}
// Subclass also satisfies the constraint
class Person extends HasName {
Person(String name) : super(name);
}
void main() {
// Valid: T is HasName
NameBox<HasName> box1 = NameBox<HasName>(HasName("Generic"));
print(box1.getValueName()); // Output: Generic
// Valid: T is Person (subclass of HasName)
NameBox<Person> box2 = NameBox<Person>(Person("Alice"));
print(box2.getValueName()); // Output: Alice
// Error: T is String (doesn't satisfy HasName constraint)
// NameBox<String> errorBox = NameBox<String>("error"); // Compile error
}
With constraints, we can safely call methods and properties of the constrained type in generic classes/functions, avoiding errors from uncertain types.
2. Constraining to Interfaces (Using Dart's Built-in Comparable)
For scenarios requiring comparison, we can use Dart's built-in Comparable interface as a constraint, ensuring type parameters implement comparison methods:
// Import Dart core library (contains Comparable interface)
import 'dart:core';
// Generic function: constraint T must implement built-in Comparable interface
T findMax<T extends Comparable>(List<T> list) {
T max = list[0];
for (T item in list) {
// Safely call compareTo method (since T is constrained to Comparable)
if (item.compareTo(max) > 0) {
max = item;
}
}
return max;
}
// Custom class implementing Comparable interface
class Student implements Comparable<Student> {
int score;
Student(this.score);
@override
int compareTo(Student other) {
return score - other.score;
}
}
void main() {
// Works with int (int implements Comparable interface)
List<int> numbers = [3, 1, 4, 2];
print(findMax(numbers)); // Output: 4
// Works with custom Student class
List<Student> students = [Student(80), Student(95), Student(70)];
print(findMax(students).score); // Output: 95
}
In this example, the findMax function uses the constraint T extends Comparable to ensure list elements can call the compareTo method, implementing a generic "find maximum value" logic. It works with both Dart's built-in types (like int and String) and custom types.
IV. Advanced Generics: Multiple Type Parameters
Generics support declaring multiple type parameters separated by commas, useful for scenarios requiring associate multiple types (like key-value pairs).
// Multiple type parameters: K for key type, V for value type
class Pair<K, V> {
K key;
V value;
Pair(this.key, this.value);
@override
String toString() => "$key: $value";
}
void main() {
// Key is String, value is int
Pair<String, int> agePair = Pair("age", 20);
print(agePair); // Output: age: 20
// Key is int, value is String
Pair<int, String> idPair = Pair(1001, "Alice");
print(idPair); // Output: 1001: Alice
}
Many built-in Dart classes (like Map) use multiple type parameters. For example, Map represents a mapping with string keys and integer values.
V. Practical Applications of Generics
Generics are common in real-world development, especially in these scenarios:
- Container classes: Collections like List, Set, and Map use generics to store and manipulate elements of any type.
- Network request utilities: Encapsulate generic network request functions by specifying the return data type:
Future<T> fetchData<T>(String url) async {
// Generic network request logic
// ...
return parsedData as T;
}
// Usage: specify return type as User
fetchData<User>("/api/user").then((user) {
print(user.name);
});
3 . State management: In Flutter state management, use generics to define generic state containers:
class StateHolder<T> {
T state;
StateHolder(this.state);
void update(T newState) {
state = newState;
// Notify listeners
}
}
Top comments (0)