where Clause
where Clause
Level 4 — Error Handling & Generics A cleaner syntax for specifying multiple or complex trait bounds.
1. Prerequisites
- Trait Bound — The mathematical constraints that the
whereclause is organizing. - Generics (
<T>) — The placeholder types being constrained.
2. Term Category
Rust-specific (syntactic formatting): As your functions get more generic and require more traits, the standard <T: Trait> syntax quickly becomes unreadable. The where clause is simply a formatting tool that moves the messy constraint logic to the end of the function signature, keeping the function name and arguments visually clean.
3. Explanation
(1) Design Motivation — "Why did we design this?"
Imagine you are writing a generic function that processes two different types, T and U.
Tmust implementDisplayandClone.Umust implementCloneandDebug.
If you write this using the standard syntax, you get an absolute monstrosity:
fn do_things<T: Display + Clone, U: Clone + Debug>(t: T, u: U) -> i32 {
// ...
}
The actual important part of the function—the arguments (t: T, u: U) and the return type -> i32—are shoved completely off the right side of the screen. It is incredibly difficult to read.
The where clause was designed to fix this visual mess. You pull all the trait bounds completely out of the angle brackets, put them after the return type under the where keyword, and list them cleanly line by line.
(2) Reality Metaphor
Imagine the front cover of a newly published book. The cover represents your function signature.
If the publisher decides to print all 50 critical reviews and the exact academic credentials of every reviewer directly on the front cover (Trait Bounds inside <>), the cover becomes a messy wall of tiny text. The actual Title of the book (the function arguments and return type) gets completely lost.
A where clause is like moving all those qualifications to the inside cover or the back of the book. The front cover stays perfectly clean and readable, but the strict information is still there if the reader (the compiler) needs to verify it.
(3) Rust Code Examples
Short Snippet (The Before and After)
Here is a direct comparison of the old syntax vs the where clause syntax. They do the exact same thing mathematically.
use std::fmt::{Display, Debug};
// THE OLD WAY: Messy and unreadable
fn process_items_old<T: Display + Clone, U: Clone + Debug>(item1: T, item2: U) -> String {
format!("{}: {:?}", item1, item2)
}
// THE NEW WAY: Clean, beautiful, and easy to read
fn process_items_new<T, U>(item1: T, item2: U) -> String
where
T: Display + Clone,
U: Clone + Debug,
{
format!("{}: {:?}", item1, item2)
}
4. Common Mistakes & Pitfalls
Mistake 1: Misunderstanding Where Clause Scoping and Lifecycle Rules
The mistake: Assuming Where Clause instances remain valid beyond their declaring scope block or across asynchronous boundaries without explicit lifetime tracking.
Why it's wrong: Rust strictly enforces lexical scope boundaries and non-lexical lifetimes (NLL) at compile time. Accessing dropped values or failing to handle variable drop order results in compiler errors such as E0597 or E0382.
Incorrect:
fn get_ref() -> &str {
let s = String::from("where_clause_data");
&s // ❌ Error E0106/E0515: returns a reference to data owned by the current function
}
Fix:
fn get_string() -> String {
let s = String::from("where_clause_data");
s // Ownership of the String is transferred directly to the caller
}
Mistake 2: Mutating Where Clause State Without Exclusive Ownership or mut Borrowing
The mistake: Attempting to mutate data associated with Where Clause through an immutable reference &T or without specifying mut in variable declarations.
Why it's wrong: Rust's aliasing XOR mutability rule (&T for shared immutable access, &mut T for exclusive mutable access) prohibits mutating state through shared references unless interior mutability patterns (e.g. RefCell, Mutex) are explicitly used.
Incorrect:
fn update_val(data: &i32) {
// *data += 1; // ❌ Error E0594: cannot assign to `*data`, which is behind a `&` reference
}
Fix:
fn update_val(data: &mut i32) {
*data += 1; // Correct: exclusive mutable reference permits mutation
}
Mistake 3: Concurrent Access to Where Clause Across Threads Without Send / Sync Guards
The mistake: Sharing non-thread-safe Where Clause instances across OS threads via std::thread::spawn.
Why it's wrong: Types that do not implement Send or Sync marker traits cannot safely cross thread boundaries. The compiler prevents data races by raising compile errors E0277 (trait Send is not implemented).
Incorrect:
use std::rc::Rc;
use std::thread;
let rc = Rc::new(42);
// thread::spawn(move || { println!("{}", rc); }); // ❌ Error E0277: `Rc` cannot be sent between threads safely
Fix:
use std::sync::Arc;
use std::thread;
let arc = Arc::new(42);
thread::spawn(move || {
println!("{}", arc); // Correct: `Arc` implements `Send` and `Sync`
});
5. Practice Exercises
Exercise 1: Generic Batch Data Ingestion Pipeline with Associated Type Bounds
Scenario: In high-throughput ETL data ingestion services, processing functions consume streamed record batches from a generic iterator, validate each record using a validation closure, and persist valid items into a target storage sink.
Without a where clause, expressing constraints across generic iterators, associated item types (I::Item), closure signature bounds, and storage sink traits results in an unreadable horizontal line of trait bounds.
Requirements:
- Define a
process_pipeline_batch<I, V, W>(iter: I, validator: V, sink: &mut W) -> Result<usize, PipelineError>function. - Refactor all type constraints into a clean
whereclause with the following bounds:I: IteratorI::Item: Clone + Debug + DisplayV: Fn(&I::Item) -> Result<(), String>W: DataSink<Item = I::Item>
- Implement
DataSinktrait with an associated typeItemand write method. - Implement a
MemorySink<T>supporting maximum capacity checks. - Fail fast returning
PipelineError::ValidationFailedif record validation fails, orPipelineError::SinkFailedif storage sink writing fails.
Answer
Implementation
use std::fmt::{Debug, Display};
#[derive(Debug, PartialEq, Eq)]
pub enum PipelineError {
ValidationFailed(String),
SinkFailed(String),
}
pub trait DataSink {
type Item;
type Error: Display;
fn write_batch(&mut self, item: Self::Item) -> Result<(), Self::Error>;
}
pub struct MemorySink<T> {
pub records: Vec<T>,
pub max_capacity: usize,
}
impl<T> MemorySink<T> {
pub fn new(max_capacity: usize) -> Self {
Self {
records: Vec::new(),
max_capacity,
}
}
}
impl<T: Debug> DataSink for MemorySink<T> {
type Item = T;
type Error = String;
fn write_batch(&mut self, item: Self::Item) -> Result<(), Self::Error> {
if self.records.len() >= self.max_capacity {
Err(format!("Sink capacity overflow: limit {}", self.max_capacity))
} else {
self.records.push(item);
Ok(())
}
}
}
pub fn process_pipeline_batch<I, V, W>(
iter: I,
validator: V,
sink: &mut W,
) -> Result<usize, PipelineError>
where
I: Iterator,
I::Item: Clone + Debug + Display,
V: Fn(&I::Item) -> Result<(), String>,
W: DataSink<Item = I::Item>,
{
let mut processed_count = 0;
for item in iter {
validator(&item).map_err(PipelineError::ValidationFailed)?;
sink.write_batch(item).map_err(|e| PipelineError::SinkFailed(e.to_string()))?;
processed_count += 1;
}
Ok(processed_count)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_successful_pipeline_processing() {
let items = vec!["event_1".to_string(), "event_2".to_string(), "event_3".to_string()];
let mut sink = MemorySink::new(10);
let validator = |item: &String| {
if item.starts_with("event_") {
Ok(())
} else {
Err("Invalid prefix".to_string())
}
};
let result = process_pipeline_batch(items.into_iter(), validator, &mut sink);
assert!(result.is_ok());
assert_eq!(result.unwrap(), 3);
assert_eq!(sink.records.len(), 3);
assert_ne!(sink.records.len(), 0);
assert!(matches!(sink.records.first(), Some(s) if s == "event_1"));
}
#[test]
fn test_validation_failure_early_exit() {
let items = vec!["event_1".to_string(), "bad_item".to_string(), "event_3".to_string()];
let mut sink = MemorySink::new(10);
let validator = |item: &String| {
if item.starts_with("event_") {
Ok(())
} else {
Err(format!("Malformed item: {}", item))
}
};
let result = process_pipeline_batch(items.into_iter(), validator, &mut sink);
assert!(result.is_err());
assert!(matches!(
result,
Err(PipelineError::ValidationFailed(ref msg)) if msg.contains("bad_item")
));
assert_eq!(sink.records.len(), 1);
assert_ne!(sink.records.len(), 3);
}
#[test]
fn test_sink_overflow_failure() {
let items = vec![100, 200, 300];
let mut sink = MemorySink::new(1);
let validator = |_: &i32| Ok(());
let result = process_pipeline_batch(items.into_iter(), validator, &mut sink);
assert!(result.is_err());
assert!(matches!(
result,
Err(PipelineError::SinkFailed(ref msg)) if msg.contains("overflow")
));
assert_eq!(sink.records.len(), 1);
}
}
Technical Explanation
- Associated Type Bounds (
I::Item): In standard angle-bracket syntax, constrainingI::Itemrequires nesting likefn process<I: Iterator<Item = T>, T: Clone...>, forcing the introduction of an extra unneeded generic parameterT. Thewhereclause permits direct targeting of associated types (I::Item: Clone + Debug + Display), keeping function generic parameter lists minimal (<I, V, W>). - Associated Type Equality Constraints (
W: DataSink<Item = I::Item>): The equality constraintDataSink<Item = I::Item>ensures static type alignment between the iterator output and the sink input without dynamic dispatch (dyn) overhead. - Closure Bounds (
V: Fn(&I::Item) -> Result<(), String>): Using theFntrait bound allowsvalidatorto be called repeatedly across iterator items without mutating or consuming closure environment state. - Monomorphization & Code Generation: The compiler generates optimized machine code monomorphized specifically for the exact concrete types passed at each call site (e.g.,
std::vec::IntoIter<String>, closure type,MemorySink<String>), eliminating virtual table lookup costs. - Fail-Fast Error Handling: The
?operator coupled with.map_err()converts domain-specific errors into unifiedPipelineErrorvariants while immediately breaking iterator evaluation upon encountering the first failure.
Exercise 2: Middleware Plugin Event Dispatcher with Conditional impl Bounds
Scenario: In decoupled microservices and plugin-based system architectures, event dispatchers route incoming domain events through security or audit logging middleware before persisting them.
When defining generic structs like EventDispatcher<E, P>, placing complex bounds on the struct declaration itself is an anti-pattern. Instead, bounds should be placed on the impl<E, P> block using a where clause.
Requirements:
- Define an
Eventtrait with associatedtype Payloadandfn topic(&self) -> &str. - Define a
Middlewaretrait with associatedtype Contextandtype Error: std::error::Error + Send + Sync + 'static. - Implement
EventDispatcher<E, P>with a conditionalimplblock using awhereclause:E: EventE::Payload: Clone + DebugP: Middleware<Context = String>
- Implement
dispatch(&mut self, ctx: &String, event: E) -> Result<(), DispatchError>handling capacity overflow (DispatchError::QueueFull) and middleware validation errors (DispatchError::MiddlewareRejected).
Answer
Implementation
use std::error::Error;
use std::fmt::{self, Debug, Display};
pub trait Event {
type Payload;
fn topic(&self) -> &str;
fn payload(&self) -> &Self::Payload;
}
pub trait Middleware {
type Context;
type Error: Error + Send + Sync + 'static;
fn process<E>(&self, event: &E, ctx: &Self::Context) -> Result<(), Self::Error>
where
E: Event;
}
#[derive(Debug)]
pub enum DispatchError {
MiddlewareRejected(String),
QueueFull,
}
impl Display for DispatchError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
DispatchError::MiddlewareRejected(msg) => write!(f, "Middleware error: {}", msg),
DispatchError::QueueFull => write!(f, "Event queue is full"),
}
}
}
impl Error for DispatchError {}
pub struct AuditLoggerMiddleware {
pub required_prefix: String,
}
#[derive(Debug)]
pub struct SecurityError(pub String);
impl Display for SecurityError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "Security policy violation: {}", self.0)
}
}
impl Error for SecurityError {}
impl Middleware for AuditLoggerMiddleware {
type Context = String;
type Error = SecurityError;
fn process<E>(&self, event: &E, ctx: &Self::Context) -> Result<(), Self::Error>
where
E: Event,
{
if !ctx.starts_with(&self.required_prefix) {
Err(SecurityError(format!(
"Context '{}' lacks prefix '{}' for topic '{}'",
ctx, self.required_prefix, event.topic()
)))
} else {
Ok(())
}
}
}
pub struct UserCreatedEvent {
pub user_id: u64,
pub username: String,
}
impl Event for UserCreatedEvent {
type Payload = String;
fn topic(&self) -> &str {
"user.created"
}
fn payload(&self) -> &Self::Payload {
&self.username
}
}
pub struct EventDispatcher<E, P> {
pub middleware: P,
pub event_log: Vec<(String, E)>,
pub max_capacity: usize,
}
impl<E, P> EventDispatcher<E, P>
where
E: Event,
E::Payload: Clone + Debug,
P: Middleware<Context = String>,
{
pub fn new(middleware: P, max_capacity: usize) -> Self {
Self {
middleware,
event_log: Vec::new(),
max_capacity,
}
}
pub fn dispatch(&mut self, ctx: &String, event: E) -> Result<(), DispatchError> {
if self.event_log.len() >= self.max_capacity {
return Err(DispatchError::QueueFull);
}
self.middleware
.process(&event, ctx)
.map_err(|e| DispatchError::MiddlewareRejected(e.to_string()))?;
self.event_log.push((event.topic().to_string(), event));
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_successful_event_dispatch() {
let middleware = AuditLoggerMiddleware {
required_prefix: "AUTH_".to_string(),
};
let mut dispatcher = EventDispatcher::new(middleware, 5);
let event = UserCreatedEvent {
user_id: 42,
username: "alice".to_string(),
};
let result = dispatcher.dispatch(&"AUTH_SESSION_99".to_string(), event);
assert!(result.is_ok());
assert_eq!(dispatcher.event_log.len(), 1);
assert_ne!(dispatcher.event_log.len(), 0);
assert_eq!(dispatcher.event_log[0].0, "user.created");
}
#[test]
fn test_middleware_rejection() {
let middleware = AuditLoggerMiddleware {
required_prefix: "AUTH_".to_string(),
};
let mut dispatcher = EventDispatcher::new(middleware, 5);
let event = UserCreatedEvent {
user_id: 43,
username: "bob".to_string(),
};
let result = dispatcher.dispatch(&"GUEST_SESSION".to_string(), event);
assert!(result.is_err());
assert!(matches!(
result,
Err(DispatchError::MiddlewareRejected(ref msg)) if msg.contains("Security policy violation")
));
assert_eq!(dispatcher.event_log.len(), 0);
}
#[test]
fn test_queue_full_error() {
let middleware = AuditLoggerMiddleware {
required_prefix: "AUTH_".to_string(),
};
let mut dispatcher = EventDispatcher::new(middleware, 1);
let e1 = UserCreatedEvent { user_id: 1, username: "a".to_string() };
let e2 = UserCreatedEvent { user_id: 2, username: "b".to_string() };
assert!(dispatcher.dispatch(&"AUTH_1".to_string(), e1).is_ok());
let err = dispatcher.dispatch(&"AUTH_1".to_string(), e2);
assert!(err.is_err());
assert!(matches!(err, Err(DispatchError::QueueFull)));
}
}
Technical Explanation
- Conditional
implBlocks viawhereClauses: Rust standard conventions recommend keeping struct definition generic declarations (struct EventDispatcher<E, P>) unconstrained or minimally constrained. Heavy trait bounds belong on theimplblock via awhereclause. This allows constructingEventDispatcherinstances cleanly and enforces operational bounds only where specific methods are actually invoked. - Trait Bound Decoupling: Constraining
P: Middleware<Context = String>ties the plugin's operational context type to a concrete type (String) inside the dispatcher's methods, while allowing the core structEventDispatcher<E, P>to remain fully polymorphic. - Error Type Erasure & Boundaries: Bound
type Error: Error + Send + Sync + 'staticenforces thread safety (Send + Sync) and owned dynamic lifetime ('static), allowing middleware error outputs to safely cross asynchronous task or OS thread boundaries when needed. - Method-Level
whereClause Constraints: InMiddleware::process<E>, the method itself has a nestedwhere E: Eventconstraint. This enables generic methods inside traits to accept any type satisfyingEventwithout requiringMiddlewareto specifyEas a type parameter on the trait itself.
Exercise 3: Generic Tiered In-Memory Cache & Storage Synchronization Layer
Scenario:
High-performance storage engine drivers maintain a fast L1 in-memory cache (HashMap<K, V>) backed by a persistent storage backend S.
Implementing generic methods for tiered caches involves multiple intersecting trait requirements: key hashability and equality (Hash + Eq), cloning semantics for values (Clone), storage driver associated type matching (S::Key = K, S::Value = V), and closure predicates for invalidation. A multi-line where clause is essential to make this code maintainable.
Requirements:
- Define a
StorageEnginetrait with associated typesKey,Value, andError: Error + Send + Sync + 'static. - Define a
MemoryStorage<K, V>struct implementingStorageEngine. - Define
TieredCache<K, V, S>containingl1_cache: HashMap<K, V>andbacking_store: S. - Use a
whereclause on theimpl<K, V, S> TieredCache<K, V, S>block to specify:K: Hash + Eq + Clone + DisplayV: Clone + DebugS: StorageEngine<Key = K, Value = V>
- Implement
fetch(&mut self, key: &K) -> Result<V, String>: return cached value if present; on L1 miss, querybacking_store.get_item(key), backfill L1 cache on success, or return readable error messages. - Implement
invalidate_and_sync<F>(&mut self, key: K, value: V, predicate: F) -> Result<bool, String>whereF: Fn(&V) -> bool: write to storage backend and update L1 cache if predicate returnstrue; evict key from L1 cache if predicate returnsfalse.
Answer
Implementation
use std::collections::HashMap;
use std::error::Error;
use std::fmt::{self, Debug, Display};
use std::hash::Hash;
pub trait StorageEngine {
type Key;
type Value;
type Error: Error + Send + Sync + 'static;
fn get_item(&self, key: &Self::Key) -> Result<Option<Self::Value>, Self::Error>;
fn put_item(&mut self, key: Self::Key, val: Self::Value) -> Result<(), Self::Error>;
}
#[derive(Debug)]
pub struct StorageError(pub String);
impl Display for StorageError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "Storage I/O error: {}", self.0)
}
}
impl Error for StorageError {}
pub struct MemoryStorage<K, V> {
pub db: HashMap<K, V>,
pub simulate_failure: bool,
}
impl<K, V> MemoryStorage<K, V>
where
K: Hash + Eq + Clone,
V: Clone,
{
pub fn new() -> Self {
Self {
db: HashMap::new(),
simulate_failure: false,
}
}
}
impl<K, V> StorageEngine for MemoryStorage<K, V>
where
K: Hash + Eq + Clone,
V: Clone,
{
type Key = K;
type Value = V;
type Error = StorageError;
fn get_item(&self, key: &Self::Key) -> Result<Option<Self::Value>, Self::Error> {
if self.simulate_failure {
Err(StorageError("Simulated storage read fault".to_string()))
} else {
Ok(self.db.get(key).cloned())
}
}
fn put_item(&mut self, key: Self::Key, val: Self::Value) -> Result<(), Self::Error> {
if self.simulate_failure {
Err(StorageError("Simulated storage write fault".to_string()))
} else {
self.db.insert(key, val);
Ok(())
}
}
}
pub struct TieredCache<K, V, S> {
pub l1_cache: HashMap<K, V>,
pub backing_store: S,
}
impl<K, V, S> TieredCache<K, V, S>
where
K: Hash + Eq + Clone + Display,
V: Clone + Debug,
S: StorageEngine<Key = K, Value = V>,
{
pub fn new(backing_store: S) -> Self {
Self {
l1_cache: HashMap::new(),
backing_store,
}
}
pub fn fetch(&mut self, key: &K) -> Result<V, String> {
if let Some(val) = self.l1_cache.get(key) {
return Ok(val.clone());
}
match self.backing_store.get_item(key) {
Ok(Some(val)) => {
self.l1_cache.insert(key.clone(), val.clone());
Ok(val)
}
Ok(None) => Err(format!("Key '{}' not found in cache or storage", key)),
Err(err) => Err(format!("Backend error fetching '{}': {}", key, err)),
}
}
pub fn invalidate_and_sync<F>(&mut self, key: K, value: V, predicate: F) -> Result<bool, String>
where
F: Fn(&V) -> bool,
{
if predicate(&value) {
self.backing_store
.put_item(key.clone(), value.clone())
.map_err(|e| e.to_string())?;
self.l1_cache.insert(key, value);
Ok(true)
} else {
self.l1_cache.remove(&key);
Ok(false)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_cache_miss_loads_from_storage() {
let mut storage = MemoryStorage::new();
storage.db.insert("user_100".to_string(), 4200);
let mut cache = TieredCache::new(storage);
let key = "user_100".to_string();
let val = cache.fetch(&key);
assert!(val.is_ok());
assert_eq!(val.unwrap(), 4200);
assert_eq!(cache.l1_cache.get(&key), Some(&4200));
assert_ne!(cache.l1_cache.len(), 0);
}
#[test]
fn test_cache_miss_not_found() {
let storage: MemoryStorage<String, i32> = MemoryStorage::new();
let mut cache = TieredCache::new(storage);
let key = "missing_key".to_string();
let val = cache.fetch(&key);
assert!(val.is_err());
assert!(matches!(val, Err(ref msg) if msg.contains("not found")));
}
#[test]
fn test_invalidate_and_sync_predicate() {
let storage = MemoryStorage::new();
let mut cache = TieredCache::new(storage);
let key = "session_key".to_string();
let accepted = cache.invalidate_and_sync(key.clone(), 99, |val| *val > 50);
assert!(accepted.is_ok());
assert_eq!(accepted.unwrap(), true);
assert_eq!(cache.l1_cache.get(&key), Some(&99));
let rejected = cache.invalidate_and_sync(key.clone(), 10, |val| *val > 50);
assert!(rejected.is_ok());
assert_eq!(rejected.unwrap(), false);
assert_eq!(cache.l1_cache.get(&key), None);
}
#[test]
fn test_storage_fault_handling() {
let mut storage: MemoryStorage<String, i32> = MemoryStorage::new();
storage.simulate_failure = true;
let mut cache = TieredCache::new(storage);
let key = "fault_key".to_string();
let result = cache.fetch(&key);
assert!(result.is_err());
assert!(matches!(result, Err(ref msg) if msg.contains("Backend error")));
}
}
Technical Explanation
- Multi-Constraint Type Parameter Alignment (
S: StorageEngine<Key = K, Value = V>): Thewhereclause explicitly binds the backing storage engine's associated types (KeyandValue) directly to the cache struct's generic key typeKand value typeV. This prevents accidental type mismatch at compile time without necessitating manual type casting or dynamic trait objects. HashMapKey Requirements (K: Hash + Eq): Standard library collections likeHashMaprequire keys to implementHashandEq. Placing these bounds in thewhereclause ofimpl<K, V, S>ensures thatl1_cacheoperational methods (e.g..get(),.insert(),.remove()) compile cleanly.- Method-Level Generic Predicate (
invalidate_and_sync<F>): The closure boundF: Fn(&V) -> boolis declared directly on the method rather than the struct level. Thewhereclause allows keepingFscoped strictly toinvalidate_and_sync, preventing type bloat onTieredCache<K, V, S>struct instances. - Ownership and Cache Backfilling: When L1 cache encounters a miss,
fetchqueriesbacking_store. Upon receivingOk(Some(val)), the value is cloned (val.clone()) and written intol1_cachebefore returning. TheV: Clonebound inside thewhereclause guarantees that value cloning is valid for any generic typeV.
6. Related Terms
- Trait Bound — What the
whereclause is literally just moving around visually. impl Trait— Another form of syntactic sugar for trait bounds.impl Traitis used to make simple, single-bound cases cleaner.whereclauses are used to make massive, multi-bound cases cleaner.- Lifetime Bounds — Related concept: Lifetime Bounds.
7. Key Takeaways
- A
whereclause does absolutely nothing new mathematically. It is purely for code formatting and readability. - It moves messy, complex trait bounds after the function's return type.
- You separate bounds in a
whereclause using commas, not semicolons. - You should almost always use it when you have more than one generic type or a type that requires multiple traits.