Chapter 10: Generics
Writing Code Once, Using It Everywhere
You've written functions that work with specific types:
fn find_int(arr: [int], target: int) -> Option<int> {
for (var i = 0; i < arr.len(); i = i + 1) {
if (arr[i] == target) {
return some(i)
}
}
return none
}This works for integers. But what about strings? Floats? Custom types? Do you copy-paste the function and change the types?
No. You use generics.
Generic Functions
A generic function works with any type:
fn find<T>(arr: [T], target: T) -> Option<int> {
for (var i = 0; i < arr.len(); i = i + 1) {
if (arr[i] == target) {
return some(i)
}
}
return none
}
fn main() -> int {
var numbers = [1, 2, 3, 4, 5]
var strings = ["hello", "world", "wyn"]
var idx1 = find(numbers, 3) // T = int
var idx2 = find(strings, "wyn") // T = string
return 0
}The <T> after the function name declares a type parameter. T is a placeholder for any type. When you call find(numbers, 3), the compiler infers T = int. When you call find(strings, "wyn"), it infers T = string.
This is called monomorphization. The compiler generates a separate version of the function for each type you use it with. There's no runtime overhead-generic code is as fast as hand-written code for each type.
Generic Structs
Structs can be generic too:
struct Pair<T, U> {
first: T,
second: U
}
fn main() -> int {
var int_pair = Pair { first: 1, second: 2 }
var mixed = Pair { first: "hello", second: 42 }
var string_pair = Pair { first: "foo", second: "bar" }
print(int_pair.first) // 1
print(mixed.first) // "hello"
print(mixed.second) // 42
return 0
}Pair<T, U> has two type parameters. You can mix and match types however you want.
Result and Option Are Generic
You've been using Result and Option. They're generic types:
enum Result<T, E> {
ok(T),
err(E)
}
enum Option<T> {
some(T),
none
}When you write Result<int, string>, you're instantiating the generic Result type with T = int and E = string.
This is why Result and Option work with any types-they're generic.
Type Aliases
Sometimes generic types get verbose:
fn process_user(result: Result<Option<User>, DatabaseError>) -> int {
// ...
}Type aliases make this cleaner:
type UserResult = Result<Option<User>, DatabaseError>
fn process_user(result: UserResult) -> int {
// ...
}Type aliases don't create new types-they're just names for existing types. But they make code more readable.
Common Type Aliases
type UserId = int
type Email = string
type Timestamp = int
fn create_user(id: UserId, email: Email, created: Timestamp) -> User {
// ...
}This documents intent. A UserId is semantically different from a random integer, even though they're the same type to the compiler.
Generic Methods
Methods on a generic struct can use its type parameters:
struct Container<T> {
value: T
}
fn Container.describe(self) -> string {
return "Container(${self.value})"
}
fn main() -> int {
var int_container = Container { value: 42 }
var string_container = Container { value: "wyn" }
print(int_container.describe()) // "Container(42)"
print(string_container.describe()) // "Container(wyn)"
return 0
}The describe method works for a Container<int> and a Container<string> alike-T is whatever the struct was instantiated with. (Method-level type parameters of their own, like a map<U> that changes the element type, aren't supported yet.)
When to Use Generics
Use generics when:
- You're writing container types (arrays, maps, trees)
- You're writing algorithms that work on any type (sorting, searching)
- You're writing utility functions (identity, swap, compare)
- You want to avoid code duplication
Don't use generics when:
- The function only makes sense for one type
- The generic version is harder to understand
- You need type-specific behavior
Examples:
// Good: generic makes sense
fn identity<T>(x: T) -> T {
return x
}
fn swap<T>(a: T, b: T) -> (T, T) {
return (b, a)
}
// Bad: only makes sense for numbers
fn add<T>(a: T, b: T) -> T {
return a + b // What if T is a string?
}
// Better: specific type
fn add(a: int, b: int) -> int {
return a + b
}Generic Collections
Arrays, HashMaps, and HashSets are generic:
var numbers: [int] = [1, 2, 3]
var strings: [string] = ["a", "b", "c"]
var int_map: HashMap<string, int> = {"a": 1, "b": 2}
var string_map: HashMap<string, string> = {"a": "x", "b": "y"}
var int_set = {:1, 2, 3} // HashSet<int>
var string_set = {:"a", "b", "c"} // HashSet<string>Usually you don't need to write the types-the compiler infers them (set literals are always inferred). But sometimes an annotation helps with clarity.
Real-World: Generic Stack
struct Stack<T> {
items: [T]
}
fn Stack.push(self, item: T) {
self.items = self.items + [item]
}
fn Stack.pop(self) -> Option<T> {
if (self.items.len() == 0) {
return none
}
var item = self.items[self.items.len() - 1]
// Remove last item (simplified)
return some(item)
}
fn Stack.is_empty(self) -> bool {
return self.items.len() == 0
}
fn main() -> int {
var int_stack = Stack { items: [1, 2] }
int_stack.push(3)
var top = int_stack.pop()
match top {
some(value) => print(value), // 3
none => print("Empty")
}
var string_stack = Stack { items: ["hello"] }
string_stack.push("world")
print(string_stack.is_empty()) // false
return 0
}One implementation, works with any type.
Real-World: Generic Cache
struct Cache<K, V> {
data: HashMap<K, V>
}
fn Cache.get(self, key: K) -> Option<V> {
if (self.data.has(key)) {
return some(self.data[key])
}
return none
}
fn Cache.set(self, key: K, value: V) {
self.data[key] = value
}
fn Cache.has(self, key: K) -> bool {
return self.data.has(key)
}
struct User {
name: string,
age: int
}
fn main() -> int {
var user_cache = Cache { data: {} }
user_cache.set("alice", User { name: "Alice", age: 30 })
var user = user_cache.get("alice")
match user {
some(u) => print(u.name),
none => print("Not found")
}
return 0
}Constraints (Future Feature)
Sometimes you need to constrain what types can be used:
// Hypothetical syntax (not yet in Wyn)
fn max<T: Comparable>(a: T, b: T) -> T {
if (a > b) {
return a
}
return b
}The T: Comparable constraint means T must support comparison operators. This prevents you from calling max with types that can't be compared.
Wyn doesn't have constraints yet, but they're planned. For now, you rely on the compiler to catch type errors when you use generic functions.
Performance Considerations
Generics have zero runtime cost. The compiler generates specialized code for each type:
fn identity<T>(x: T) -> T {
return x
}
var a = identity(42) // Generates identity_int
var b = identity("hello") // Generates identity_stringThis is called monomorphization. It's the same approach Rust uses. The downside is larger binary size-each instantiation adds code. The upside is maximum performance.
Generic Best Practices
Use Descriptive Type Parameter Names
// Good: clear meaning
fn map<T, U>(arr: [T], f: fn(T) -> U) -> [U]
// Bad: unclear
fn map<A, B>(arr: [A], f: fn(A) -> B) -> [B]Convention: T for "type", U for "second type", K for "key", V for "value", E for "error".
Don't Over-Generalize
// Bad: too generic
fn process<T, U, V, W>(a: T, b: U, c: V) -> W {
// What does this even do?
}
// Good: specific and clear
fn format_user(user: User, template: string) -> string {
// Clear purpose
}Document Generic Functions
// Finds the first occurrence of target in arr.
// Returns Some(index) if found, None otherwise.
fn find<T>(arr: [T], target: T) -> Option<int> {
// ...
}What You've Learned
Generics let you write code that works with any type. Functions, structs, and methods can all be generic.
Type parameters are placeholders for concrete types. The compiler infers them from usage or you can specify them explicitly.
Monomorphization generates specialized code for each type. This gives you zero-cost abstraction-generic code is as fast as hand-written code.
Type aliases make complex generic types more readable. Use them to document intent and reduce verbosity.
Result and Option are generic types. That's why they work with any types.
Exercises
Generic swap: Write a generic function that swaps two values and returns them as a tuple.
Generic min/max: Write generic functions to find the minimum and maximum of two values. What happens when you try to use them with strings?
Generic container: Implement a generic
Box<T>type that wraps a single value. Add methods to get and set the value.Generic filter: Write a generic function that filters an array based on a predicate function.
Type aliases: Create type aliases for common Result and Option combinations in a hypothetical web application (e.g.,
UserResult,PostResult).Generic tree: Design (don't fully implement) a generic binary tree structure. What methods would it need?
Refactor: Take code that duplicates logic for different types and refactor it to use generics.