struct and trait

31 min read
On this page

struct describes data and type identity, while trait describes required behaviors, associated types, and constants. Rust connects behavior to types via impl, eliminating the need to organize all reuse relationships through a class inheritance tree.

Three Structure Types and New Types

Named fields are suitable for data where field meaning matters; tuple structs are suitable for wrapping where position is explicit; unit structs have no fields and can serve as markers or stateless strategies. type Meters = f64 is merely an alias, whereas struct Meters(f64) creates a distinct type.

External raw inpute.g., f64 meters1Constructor validationReject NaN, infinity, and negative numbers2Private field Meters3New types maintain semantics through controlled entry pointsMethods can rely on invariants established at constructionChanging just the type name is not enough; if arbitrary field writes are exposed, callers can still bypass validation.
mod units {
    #[derive(Debug, Clone, Copy, PartialEq)]
    pub struct Meters(f64);
    impl Meters {
        pub fn new(value: f64) -> Option<Self> {
            if value.is_finite() && value >= 0.0 { Some(Self(value)) } else { None }
        }
        pub fn value(self) -> f64 { self.0 }
    }
}
fn main() {
    let distance = units::Meters::new(12.5).unwrap();
    assert_eq!(distance.value(), 12.5);
    assert!(units::Meters::new(-1.0).is_none());
    assert!(units::Meters::new(f64::NAN).is_none());
}

Outside the module, private fields cannot be constructed or modified directly, so values can only be created through specified entry points. New types prevent passing Feet directly as Meters parameters, but if raw fields are exposed and added arbitrarily, unit errors can still occur.

Single-field wrappers do not automatically guarantee the same ABI as the inner type; if you need to satisfy specific layout constraints, look into repr(transparent) rather than treating "zero-cost" as a guarantee for all representation details.

New type invariants must cover all modification entry points

Meters::new rejects negative numbers, NaN, and infinity, establishing the invariant that "the current value is finite and non-negative." If an add method is added later and performs direct f64 addition, the sum of two finite values might still overflow to infinity, so the method should also re-validate the result.

Whether to provide DerefMut<Target = f64> also affects encapsulation: it would allow callers to write arbitrary f64 values directly, bypassing construction validation. Serialization/deserialization, From/TryFrom, default values, and arithmetic traits are also creation or modification entry points; simply making fields private does not automatically complete the entire invariant proof.

The following example uses a finite integer range to illustrate the failure behavior of "validate first, assign later." When set returns an error, the old value is preserved, so callers don't have to guess whether the object was partially modified after failure.

mod limits {
    #[derive(Debug, PartialEq)]
    pub struct Percent(u8);
    impl TryFrom<u16> for Percent {
        type Error = &'static str;
        fn try_from(value: u16) -> Result<Self, Self::Error> {
            if value <= 100 { Ok(Self(value as u8)) } else { Err("out of range") }
        }
    }
    impl Percent {
        pub fn value(&self) -> u8 { self.0 }
        pub fn set(&mut self, value: u16) -> Result<(), &'static str> {
            let checked = Self::try_from(value)?;
            *self = checked;
            Ok(())
        }
    }
}
fn main() {
    let mut value = limits::Percent::try_from(40).unwrap();
    assert_eq!(value.set(101), Err("out of range"));
    assert_eq!(value.value(), 40);
    value.set(100).unwrap();
    assert_eq!(value.value(), 100);
}

From is suitable for cases with no failure and conforming to conversion semantics, while TryFrom explicitly writes potential failures into the type; do not silently truncate illegal values into another meaning just for the convenience of .into(). Here, as u8 is placed within the branch already checked for ≤100, and the safe range is established by preconditions. TryFrom

Struct update syntax does not call Clone

Config { retries: 5, ..old } only retrieves fields from old that are not explicitly provided: Copy fields are copied, and other fields are moved. It does not clone the entire object, nor does it inherit from another instance.

struct Config { endpoint: String, retries: u8 }
fn main() {
    let old = Config { endpoint: "localhost".into(), retries: 2 };
    let next = Config { retries: 5, ..old };
    assert_eq!(next.endpoint, "localhost");
    assert_eq!(next.retries, 5);
    // endpoint has been moved, but retries was not moved, so it can be read separately.
    assert_eq!(old.retries, 2);
}

You can no longer borrow the entire old to print or call methods that depend on the complete object. The update syntax is also subject to rules such as field visibility and Drop; outside the module, you cannot bypass private fields to construct an object via ..old. Struct update

trait declares capabilities, impl provides implementation

Trait methods can have default implementations, and associated types are usually specified by the implementation; default methods can use capabilities already promised by the trait. &self borrowing, &mut self exclusive borrowing, and self consuming the value are all important components of interface behavior.

trait Summary {
    fn summary(&self) -> String;
    fn decorated(&self) -> String { format!("[{}]", self.summary()) }
}
struct Point { x: i32, y: i32 }
impl Summary for Point {
    fn summary(&self) -> String { format!("{},{}", self.x, self.y) }
}
fn render<T: Summary>(item: &T) -> String { item.decorated() }
fn main() {
    assert_eq!(render(&Point { x: 2, y: 3 }), "[2,3]");
}

The compiler can check signatures and type requirements, but it does not prove all business contracts. For example, if a method promises ordered results, or that Eq/Hash must be consistent, these semantics are still maintained by the implementer. A trait is not just a list of function names. Traits

Receivers are also resource lifetime design

&self: lend shared accessReadable interface allows data access; interior mutability has separate rules&mut self: lend exclusive accessThe method is responsible for maintaining invariants; it can check before submitting modificationsself: hand over this valueMethod receiver determines what the caller hands overSuitable for finish / into_inner; cannot continue using the original value after consumptionReceiver constraints are part of the interface; passing compilation does not prove that the method adheres to semantics such as ordering, equivalence, or error recovery.

Borrowing and consuming are not just writing preferences. finish(self) indicates that the object has entered its final stage, and the caller hands over ownership; finish(&mut self) requires defining what usable state the object remains in after completion. For reusable buffers, the latter might be appropriate; for builders that cannot be resubmitted, the former expresses intent more clearly.

Shared references do not absolutely mean "the method makes no modifications": interior mutability types like Cell, RefCell, and Mutex allow changing internal state according to their own rules. Trait documentation should supplement constraints regarding threads, reentrancy, post-error state, and side effects; you cannot infer pure functions solely from &self.

Same method names: eliminate ambiguity with fully qualified syntax

trait HumanLabel { fn label(&self) -> &'static str; }
trait MachineLabel { fn label(&self) -> &'static str; }
struct Device;
impl HumanLabel for Device { fn label(&self) -> &'static str { "device" } }
impl MachineLabel for Device { fn label(&self) -> &'static str { "dev-7" } }
fn main() {
    let value = Device;
    assert_eq!(<Device as HumanLabel>::label(&value), "device");
    assert_eq!(<Device as MachineLabel>::label(&value), "dev-7");
}

Method calls combine auto-deref, auto-borrowing, and search for candidate traits visible in the scope; "always prefer inherent methods first" is an oversimplification because different receiver candidates have a priority order. When ambiguity exists, explicitly specifying type, trait, and receiver is more helpful than blindly adding clone. Method lookup

derive: generates implementations, but also introduces constraints

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
struct GridPoint { x: i32, y: i32 }

fn main() {
    let point = GridPoint { x: 1, y: 2 };
    assert_eq!(point, point);
    let mut points = std::collections::HashSet::new();
    points.insert(point);
    assert!(points.contains(&point));
}

When switching to f64 fields, directly deriving Eq and Hash becomes unavailable; floating-point semantics like NaN require explicit handling and cannot be mechanically copied from integer type derivation lists. Deriving Clone is also not an automatic recursive "deep copy": cloning an Arc field still only increases the shared owner count.

derive is a macro mechanism: the standard library provides a set of built-in derives, while Serialize etc. come from third-party procedural macros. Automatically generated generic constraints are sometimes more conservative than manual implementations; when necessary, you should check the actual constraints and write manual implementations, rather than arbitrarily adding Clone to all generic parameters.

Derive constraints may be stronger than what the fields actually require

For example, Arc<T> can be cloned even if T does not implement Clone, because it copies shared ownership, not T itself. Directly deriving Clone for a generic wrapper containing Arc<T> may generate implementations that require T: Clone; a manual implementation can express more accurate constraints.

use std::sync::Arc;
struct Shared<T>(Arc<T>);
impl<T> Clone for Shared<T> {
    fn clone(&self) -> Self { Self(Arc::clone(&self.0)) }
}
struct NotClone { value: u32 }
fn main() {
    let a = Shared(Arc::new(NotClone { value: 7 }));
    let b = a.clone();
    assert!(Arc::ptr_eq(&a.0, &b.0));
    assert_eq!(b.0.value, 7);
    assert_eq!(Arc::strong_count(&a.0), 2);
}

Here, NotClone does not have Clone added, nor is the underlying object copied. Whether to share, copy independently, or prohibit copying depends on resource semantics; you cannot treat "derive can generate" as a reason for interface selection. Clone derive

Eq and Hash are a shared contract across methods

If a == b, they must produce the same hash; the reverse is not true because hash collisions are allowed. If equality is determined only by user_id, but the mutable display_name is also hashed, two keys deemed equal might fall into different lookup paths, breaking the usage assumptions of HashMap/HashSet.

Desired SemanticsImplementation Direction
All fields participate in value equalityUniformly derive PartialEq/Eq/Hash for fields that meet the conditions
Equal only by stable IDUse the same set of identity fields for comparison and hashing
Case-insensitive identifierNormalize at construction, or use consistent comparison/hashing rules
Allow floating-point special valuesFirst choose semantics for NaN, ±0, etc., then choose wrapping and implementation

After a key is inserted into a collection, you should not change the content determining Eq/Hash through interior mutability. The standard library treats such contract violations as logic errors; contract violations for generally safe traits should not be assumed by unsafe code as impossible, leading to memory unsafety. Hash contract, PartialEq contract

Why can an equal key miss?

A trait contract can span implementations. Case-insensitive Eq with case-sensitive Hash may send equal keys to different candidates. The model deliberately permits that broken combination to separate type checking from behavioral consistency. Hash collisions are allowed; the implication runs from equality to equal hashes.

Preparing the visual
Why can an equal key miss?

Keys equal under Eq must hash equally; otherwise lookup may visit a different candidate bucket.

Orphan rule and implementation overlap

Trait implementations must satisfy both the orphan rule and non-conflict requirements. Roughly speaking, the trait must be locally defined, or involve a local type that conforms to the rules; generic implementations also have overlap and parameter ordering requirements. Checks occur during compiler trait coherence rules, not by waiting for the linker to randomly pick an implementation. Implementation coherence

Implementation FormTypical Result in Current Crate
External trait for local MyTypeAllowed, but still avoid overlap
Local MyTrait for external StringAllowed, but still avoid overlap
External Display for external StringNot allowed
External Display for VecNot allowed; Vec does not become a local type just because it contains local types internally

fundamental types like Box have specific handling in the rules and cannot be generalized to "any generic container is local as long as its parameters are local." When you need to provide your own display for external containers, a new type is usually clearer:

use std::fmt;
struct Numbers(Vec<i32>);
impl fmt::Display for Numbers {
    fn fmt(&self, output: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(output, "{:?}", self.0)
    }
}
fn main() {
    assert_eq!(Numbers(vec![1, 2]).to_string(), "[1, 2]");
}

Even if the trait is local, impl<T> MyTrait for T might overlap with a more specific implementation; the orphan rule does not mean arbitrary overriding of valid implementations is allowed.

Constraints hand implementation capabilities to generic callers

T: Summary + Clone means requiring both capabilities simultaneously; where clauses are simply a writing style better suited for complex relationships. Parent traits express prerequisite capabilities, not inheritance structure fields. Constraints on associated types and dyn object limitations are covered in Associated Types and GATs and Dynamic Dispatch respectively.

Design public traits: treat the implementable space as part of the API

Defining a trait is equivalent to allowing callers to depend on it, and potentially allowing downstream users to implement it for their own types. Adding methods without default implementations will leave downstream implementations incomplete; adding default methods can also bring name conflicts, behavioral changes, or dyn compatibility changes, so you cannot simply assume "having a default implementation means no risk."

Overriding implementations like impl<T: Display> MyTrait for T provide capabilities for a large class of types, but also occupy the space for these types to write specific MyTrait implementations. It is not a runtime plugin priority rule; you cannot expect more specific impl to automatically override generic impl; stable Rust's coherence checks will reject such overlaps.

If you only intend to allow the library itself to implement it, you can adopt an intentional sealed trait design; if you want external extension, try to keep required methods few and stable, and place composable convenience methods in reasonable positions. Both closed and open approaches have their uses, and documentation should explain which capabilities are commitments and which are extension points. Cargo SemVer and trait changes

The most valuable aspect of combining struct and trait is separating the definition of data ownership, invariants, and behavior contracts, and then connecting them via controlled impl interfaces. Even if an interface passes type checking, it still requires consistent equality/hash, post-failure state, and evolution conventions for callers to rely on it reliably.