Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Rust FAQ: Traits -- Composition vs. Inheritance

PART11 -- Traits and Inheritance (Composition vs. Inheritance)

Q68: How do you express composition in Rust?

Composition in Rust involves building complex types by embedding simpler types (e.g., structs or enums) as fields within another type, rather than relying on inheritance. This allows a struct to “contain” other types and delegate functionality to them, promoting modularity and flexibility.

How to express composition:

  • Define a struct that includes other structs or types as fields.
  • Provide methods to access or delegate to the inner types’ functionality.
  • Optionally implement traits to share behavior across types.

Example:

struct Engine {
    horsepower: u32,
}

impl Engine {
    fn start(&self) -> String {
        format!("Engine with {} horsepower started", self.horsepower)
    }
}

struct Car {
    engine: Engine, // Composition: Car contains Engine
    color: String,
}

impl Car {
    fn new(horsepower: u32, color: &str) -> Car {
        Car {
            engine: Engine { horsepower },
            color: color.to_string(),
        }
    }

    fn start_engine(&self) -> String {
        self.engine.start() // Delegate to Engine
    }
}

fn main() {
    let car = Car::new(200, "Red");
    println!("Car color: {}", car.color);
    println!("{}", car.start_engine()); // Prints: Engine with 200 horsepower started
}

Key Points:

  • Data Composition: The Car struct contains an Engine, allowing it to use Engine’s data and methods.
  • Delegation: Car can expose Engine’s functionality through its own methods (e.g., start_engine).
  • Flexibility: You can change Engine’s implementation or swap it for another type without affecting Car’s external interface.
  • No Inheritance: Unlike inheritance, Car doesn’t inherit Engine’s behavior; it explicitly includes and delegates to it.

Composition is idiomatic in Rust, aligning with its focus on explicitness and safety.

Q69: How are composition and trait implementation similar/dissimilar?

Composition and trait implementation are two Rust mechanisms for code reuse, often used together, but they serve different purposes and have distinct characteristics.

Similarities:

  • Code Reuse: Both enable sharing functionality across types without duplicating code.
  • Modularity: They promote modular design by separating concerns (data for composition, behavior for traits).
  • Flexibility: Both allow types to combine functionality from multiple sources, unlike single inheritance in some languages.
  • Safety: Both work within Rust’s type system, ensuring memory and thread safety.

Dissimilarities:

  • Purpose:
    • Composition: Focuses on data reuse by embedding one type inside another. It’s about structuring data and delegating to contained types.
    • Trait Implementation: Focuses on behavior reuse by defining methods that types can implement, independent of their data.
  • Mechanism:
    • Composition: Involves including a struct or type as a field and accessing its data or methods:
      #![allow(unused)]
      fn main() {
      struct Wheel {
          size: u32,
      }
      
      struct Car {
          wheel: Wheel,
      }
      }
    • Trait Implementation: Involves implementing a trait’s methods for a type, which may not involve any data:
      #![allow(unused)]
      fn main() {
      trait Drive {
          fn drive(&self) -> String;
      }
      
      impl Drive for Car {
          fn drive(&self) -> String { String::from("Driving!") }
      }
      }
  • Access:
    • Composition: Provides direct access to the inner type’s fields or methods (subject to visibility rules).
    • Trait Implementation: Provides access only to the trait’s methods, not the type’s internal data.
  • Inheritance:
    • Composition: Mimics “has-a” relationships (e.g., a Car has a Wheel).
    • Trait Implementation: Mimics “is-a” relationships (e.g., a Car is a Drive-able thing).
  • Granularity:
    • Composition: Works at the type level, bundling data and behavior together.
    • Trait Implementation: Works at the behavior level, allowing fine-grained control over which methods a type supports.

Example Combining Both:

trait Vehicle {
    fn move_it(&self) -> String;
}

struct Engine {
    horsepower: u32,
}

struct Car {
    engine: Engine, // Composition
}

impl Vehicle for Car {
    fn move_it(&self) -> String {
        format!("Car with {} horsepower is moving", self.engine.horsepower)
    }
}

fn main() {
    let car = Car { engine: Engine { horsepower: 200 } };
    println!("{}", car.move_it()); // Prints: Car with 200 horsepower is moving
}

Summary:

  • Similar: Both enable code reuse and modularity.
  • Dissimilar: Composition handles data inclusion; trait implementation handles shared behavior.
  • Use Together: Composition for data structure, traits for shared functionality.

Q70: Should I cast from a trait object to a supertrait?

Casting a trait object (dyn Trait) to a supertrait (a trait that another trait inherits) is possible in Rust and can be useful, but whether you should do it depends on your use case. A supertrait is a trait that another trait requires via a bound (e.g., trait SubTrait: SuperTrait).

When to cast to a supertrait:

  • Access Broader Behavior: If you need to use methods defined in the supertrait that aren’t available in the subtrait.
  • Polymorphism: When you want to treat objects uniformly under the supertrait’s interface, especially in collections or functions.
  • Example:
    trait SuperTrait {
        fn super_method(&self);
    }
    
    trait SubTrait: SuperTrait {
        fn sub_method(&self);
    }
    
    struct MyType;
    
    impl SuperTrait for MyType {
        fn super_method(&self) { println!("Super method"); }
    }
    
    impl SubTrait for MyType {
        fn sub_method(&self) { println!("Sub method"); }
    }
    
    fn use_super(super: &dyn SuperTrait) {
        super.super_method();
    }
    
    fn main() {
        let obj: Box<dyn SubTrait> = Box::new(MyType);
        let super_obj: &dyn SuperTrait = &*obj; // Cast to supertrait
        use_super(super_obj); // Prints: Super method
    }

When to avoid:

  • Performance: Casting to a supertrait involves dynamic dispatch, which has a small runtime cost. If you know the concrete type or can use generics, static dispatch is faster.
  • Complexity: Casting adds complexity, especially if the supertrait’s methods aren’t needed.
  • Object Safety: Both traits must be object-safe (no generic methods or Self constraints) for trait objects to work.

How it works:

  • Since SubTrait requires SuperTrait, any type implementing SubTrait also implements SuperTrait.
  • Casting a Box<dyn SubTrait> or &dyn SubTrait to &dyn SuperTrait is safe because Rust’s vtable includes the supertrait’s methods.

Best Practice:

  • Cast to a supertrait when you need to pass a trait object to a function or collection expecting the supertrait.
  • Prefer generics (T: SubTrait) for performance if you don’t need runtime polymorphism.
  • Ensure the cast is necessary; if you only need SubTrait’s methods, avoid the cast.

Q71: Should I cast from a struct to a trait it implements?

Casting a struct to a trait it implements (e.g., from MyStruct to &dyn MyTrait) is common in Rust when you need polymorphism via trait objects, but whether you should do it depends on your needs. This cast allows you to treat a concrete type as an instance of a trait, enabling dynamic dispatch.

When to cast:

  • Heterogeneous Collections: To store different types implementing the same trait in a collection (e.g., Vec<Box<dyn Trait>>).
  • Runtime Flexibility: When the specific type isn’t known at compile time (e.g., plugins or user input).
  • API Requirements: When a function or API expects a trait object.
  • Example:
    trait Draw {
        fn draw(&self);
    }
    
    struct Circle;
    
    impl Draw for Circle {
        fn draw(&self) { println!("Drawing a circle"); }
    }
    
    fn draw_all(shapes: &[&dyn Draw]) {
        for shape in shapes {
            shape.draw();
        }
    }
    
    fn main() {
        let circle = Circle;
        let shapes: Vec<&dyn Draw> = vec![&circle]; // Cast to trait object
        draw_all(&shapes); // Prints: Drawing a circle
    }

When to avoid:

  • Performance: Trait objects use dynamic dispatch, which has a small runtime cost (vtable lookup). Use generics (T: Trait) for static dispatch if performance is critical.
  • Unnecessary Complexity: If you’re working with a single type and don’t need polymorphism, casting to a trait object adds overhead.
  • Object Safety: The trait must be object-safe (no generic methods or Self constraints).

How to cast:

  • Use &my_struct as &dyn Trait for references.
  • Use Box::new(my_struct) as Box<dyn Trait> for owned trait objects.
  • The cast is safe because Rust ensures the type implements the trait.

Best Practice:

  • Cast to a trait object when you need polymorphism or to satisfy an API requiring dyn Trait.
  • Prefer generics or impl Trait for static dispatch when types are known at compile time.
  • Minimize heap allocations by using &dyn Trait over Box<dyn Trait> when possible.

Q72: What are the visibility rules with traits and impls?

Rust’s visibility rules for traits and their implementations (impl blocks) are governed by the module system and the pub, pub(crate), or private modifiers. Here’s how they work:

  • Trait Visibility:

    • A trait’s visibility determines where it can be used or implemented.
    • pub: The trait is visible to all code, including other crates, and can be implemented by any type (subject to orphan rules).
    • pub(crate): The trait is visible only within the current crate, limiting its use and implementation to the crate.
    • Private (no modifier): The trait is visible only within its defining module, restricting implementation and usage to that module.
    • Example:
      mod my_module {
          pub trait PublicTrait {
              fn method(&self);
          }
      
          pub(crate) trait CrateTrait {
              fn method(&self);
          }
      
          trait PrivateTrait {
              fn method(&self);
          }
      
          pub struct MyType;
      
          impl PublicTrait for MyType {
              fn method(&self) { println!("Public trait"); }
          }
      
          impl CrateTrait for MyType {
              fn method(&self) { println!("Crate trait"); }
          }
      
          impl PrivateTrait for MyType {
              fn method(&self) { println!("Private trait"); }
          }
      }
      
      fn main() {
          let obj = my_module::MyType;
          obj.method(); // Works for PublicTrait
          // obj.method() for CrateTrait or PrivateTrait would fail outside my_module
      }
  • Impl Visibility:

    • The impl block itself doesn’t have visibility modifiers; it inherits the visibility of the trait and type.
    • If the trait or type is pub, the impl is accessible wherever both are visible.
    • If either the trait or type is private or pub(crate), the impl is restricted accordingly.
    • Example:
      mod my_module {
          pub trait Trait {
              fn method(&self);
          }
      
          struct PrivateType;
      
          impl Trait for PrivateType {
              fn method(&self) { println!("Private type"); }
          }
      
          pub fn use_trait(obj: &impl Trait) {
              obj.method();
          }
      }
      
      fn main() {
          // let obj = my_module::PrivateType; // Error: PrivateType is private
          // But my_module::use_trait can accept a PrivateType if created inside my_module
      }
  • Method Visibility:

    • Trait methods are always accessible where the trait is visible; you can’t make them private in an impl block.
    • To restrict access, make the trait or type private, or use a private struct with a public trait (see Q60).
    • Example:
      mod my_module {
          pub trait Trait {
              fn method(&self);
          }
      
          pub struct MyType;
      
          impl Trait for MyType {
              fn method(&self) { println!("Method"); }
          }
      }
      
      fn main() {
          let obj = my_module::MyType;
          obj.method(); // Works: trait and type are public
      }
  • Orphan Rules: You can only implement a trait for a type if either the trait or the type is defined in your crate, ensuring visibility doesn’t lead to conflicting implementations.

Best Practice:

  • Use pub for public APIs, pub(crate) for crate-internal traits, and private for module-local traits.
  • Restrict struct visibility to control access to trait implementations.
  • Use factory functions or sealed traits to further limit access (see Q60, Q65).

Q73: Do most Rust programmers use composition or traits for code reuse?

Most Rust programmers use both composition and traits for code reuse, as they complement each other, but the choice depends on the use case. Rust’s design encourages a combination of these approaches over traditional inheritance, and the community leans heavily on idiomatic patterns that leverage both.

Composition:

  • When Used: For reusing data and structuring types by embedding one type within another.
  • Common Cases:
    • Building complex structs from simpler ones (e.g., a Car containing an Engine).
    • Managing ownership and lifetimes explicitly.
  • Popularity: Widely used when data organization is the focus, as it’s straightforward and aligns with Rust’s ownership model.
  • Example:
    #![allow(unused)]
    fn main() {
    struct Engine { horsepower: u32 }
    struct Car { engine: Engine }
    }

Traits:

  • When Used: For sharing behavior across unrelated types without coupling them to data.
  • Common Cases:
    • Defining interfaces (e.g., Debug, Clone, Iterator) for polymorphism.
    • Enabling generic programming or dynamic dispatch.
  • Popularity: Extremely common, especially for library code, as traits enable flexible, reusable APIs (e.g., std::io::Read, std::fmt::Display).
  • Example:
    #![allow(unused)]
    fn main() {
    trait Drive { fn drive(&self); }
    impl Drive for Car { fn drive(&self) { println!("Driving!"); } }
    }

Community Trends:

  • Traits for Behavior: Rust programmers heavily use traits for code reuse when defining shared functionality, especially in libraries. Traits like Debug, Clone, or custom ones are standard for polymorphism.
  • Composition for Data: Composition is the go-to for structuring data, as Rust avoids inheritance. It’s common to see structs embedding other structs or types like Vec or Option.
  • Combined Approach: Most real-world Rust code combines both:
    • Use composition to define data (e.g., a struct with fields).
    • Use traits to add behavior to those structs.
    • Example: The standard library’s Vec uses composition (it contains a raw buffer) and implements traits like Iterator or Deref for functionality.
  • Evidence from Ecosystem: Popular crates like serde (serialization), tokio (async), and std rely on traits for extensibility and composition for data management. For instance, serde’s Serialize and Deserialize traits are implemented for structs composed of various fields.

Which is more common?

  • Traits are slightly more prominent for code reuse in public APIs and libraries because they enable generic, flexible interfaces across types.
  • Composition is ubiquitous for structuring data within applications, as it’s the natural way to build complex types.
  • Programmers often use both together, with composition handling data and traits handling behavior, as this aligns with Rust’s philosophy of explicitness and modularity.

Best Practice:

  • Use composition when you need to structure data or reuse existing types (e.g., embedding a Vec or custom struct).
  • Use traits when you need shared behavior or polymorphism (e.g., defining a Drawable trait for multiple shapes).
  • Combine them for complex systems: compose data with structs, then implement traits for shared functionality.