Skip to content
HN On Hacker News ↗

Nine Ways to Do Inheritance in Rust, a Language Without Inheritance

▲ 87 points 21 comments by pjmlp 3w ago HN discussion ↗

Pangram verdict · v3.3

We believe that this document is primarily human-written, with some AI-generated content detected

23 %

AI likelihood · overall

Mixed
90% human-written 10% AI-generated
SEGMENTS · HUMAN 6 of 6
SEGMENTS · AI 0 of 6
WORD COUNT 1,668
PEAK AI % 24% · §6
Analyzed
Jun 6
backend: pangram/v3.3
Segments scanned
6 windows
avg 278 words each
Distribution
90 / 10%
human / AI fraction
Verdict
Mixed
Pangram v3.3

Article text · 1,668 words · 6 segments analyzed

Human AI-generated
§1 Human · 18%

17 min read3 days ago--Press enter or click to view image in full sizeA Rust crab builds small, explicit structures from simple pieces while the shadow of a class-inheritance tree looms behind it. Rust can achieve many inheritance-like effects, but it does so through traits, impls, bounds, macros, and composition rather than a class hierarchy. Image created with ChatGPT.For a video version of this article, see this talk to the Seattle Rust User Group on the Rust Videos YouTube channel. See CarlKCarlK/inherit on GitHub for this project’s code.Rust does not have class inheritance. That is true in the narrow, language-feature sense: there are no classes, no subclass declarations, and no fields inherited from a parent class.But when people reach for inheritance in an object-oriented language, they are usually trying to get one of a three effects:shared interfaces: the same generic code can work with different concrete typesshared behavior: many types can reuse one implementationsometimes, shared storage: a subtype gets fields from a supertypeRust does not give us the third one directly. It does, however, give us a surprisingly rich set of tools for the first two. In this article, I will walk through nine inheritance-shaped problems and the Rust techniques that solve them.Aside: In this article, I will use “abstract class,” “interface,” and “trait” as three ways to talk about a role or contract. In Rust, the mechanism is usually a trait: something a concrete type can implement. By contrast, when I say “concrete class,” think Rust struct or enum: a type from which you can actually create values.The article is organized around nine small puzzles. Each puzzle starts with an object-oriented design, then asks how we should express the same idea in Rust. The puzzles are:Giving Every Integer a Shared Helper MethodMaking an Animated Servo Still Count as a ServoAdding a Method to a Type You Don’t OwnGiving a Tiny Enum a Full Set of Standard BehaviorsMaking a Wrapper Feel Like the Thing Inside ItAdding union() to Any Collection of Range SetsTreating Fifteen Integer-Like Types the Same WayGiving Only OutputArray<8> a Byte-Oriented MethodSaving Only Serializable Values to FlashLet’s start with the simplest case: several types need the same interface, and they should share one helper method.1.

§2 Human · 1%

Giving Every Integer a Shared Helper MethodThe RangeSetBlaze crate stores mathematical sets of integers: u8, i16, and so on. The crate works with integers through methods such as min_value and max_value that each integer type must define. Using those required methods, we also want shared code for additional methods such as exhausted_range.In an object-oriented class diagram, we might draw an abstract Integer class with required methods and one implemented method inherited by concrete integer types.Press enter or click to view image in full sizePuzzle: How could you implement this in Rust?Solution: In Rust, we can define a trait with two required methods and one default method, exhausted_range:use std::ops::RangeInclusive;trait Integer: Copy + Ord { fn min_value() -> Self; fn max_value() -> Self; fn exhausted_range() -> RangeInclusive<Self> { debug_assert!(Self::min_value() < Self::max_value(), "Precondition"); Self::max_value()..=Self::min_value() }}The shared code lives once in the trait. Each implementor provides the code for its min_value and max_value:impl Integer for u8 { fn min_value() -> Self { u8::MIN } fn max_value() -> Self { u8::MAX }}impl Integer for i16 { fn min_value() -> Self { i16::MIN } fn max_value() -> Self { i16::MAX }}We use the methods like so:let r1 = u8::exhausted_range();let r2 = i16::exhausted_range();assert_eq!(r1, 255..=0);assert!(r2.is_empty());We call this technique Trait Default Methods. A trait defines the required interface, and any method with a body becomes shared behavior for implementors that do not override it.But what if we want even more structure?2. Making an Animated Servo Still Count as a ServoThe device-envoy crate helps you write high-level Rust applications on microcontrollers. It works with the ESP32 and Raspberry Pi Pico families of microcontrollers. Among other things, it can help you control a servo motor.A servo is an electric motor that we can instruct to move to a specific angle. Let’s model that as an abstract class called Servo.

§3 Human · 10%

We also want an abstract class called ServoPlayer. Every ServoPlayer is a Servo, but a ServoPlayer also knows how to animate through a sequence of angles and hold times.For a specific microcontroller family, such as ESP32, we want concrete classes that inherit from Servo and ServoPlayer.The class diagram looks like this:Press enter or click to view image in full sizePuzzle: How would we implement this in Rust?Solution: In Rust, one trait can require another trait:trait Servo { fn set_degrees(&self, degrees: u16);}trait ServoPlayer: Servo { fn animate(&self, steps: &[(u16, u64)]);}The : Servo part says that every ServoPlayer must also be a Servo. In Rust terms, ServoPlayer has Servo as a supertrait.Aside: In all these examples, we mock up small, self-contained code that illustrates the point. See the real crates for the full code. In this example, rather than really controlling a servo motor, we just print a message.

§4 Human · 20%

A concrete type that implements only Servo could look like this:#[derive(Default)]struct ServoEsp;impl Servo for ServoEsp { fn set_degrees(&self, degrees: u16) { println!("[ServoEsp] set angle -> {degrees}°"); }}A concrete type that implements ServoPlayer must also implement Servo:#[derive(Default)]struct ServoPlayerEsp;impl Servo for ServoPlayerEsp { fn set_degrees(&self, degrees: u16) { println!("[ServoPlayerEsp] set angle -> {degrees}°"); }}impl ServoPlayer for ServoPlayerEsp { fn animate(&self, steps: &[(u16, u64)]) { for (degrees, _milliseconds) in steps { self.set_degrees(*degrees); } }}Aside: Notice that ServoPlayerEsp implements Servo in one impl block and ServoPlayer in another. That is because Rust treats ServoPlayer: Servo as “ServoPlayer requires Servo,” not as “ServoPlayer inherits the Servo implementation.”Then generic code can ask for exactly the capability it needs:fn center_servo(servo: &impl Servo) { servo.set_degrees(90);}fn run_wave(player: &impl ServoPlayer) { player.animate(&[(0, 120), (90, 100), (180, 120)]);}We can pass a ServoPlayerEsp to both functions. We can pass a plain ServoEsp only to center_servo:let servo_esp = ServoEsp::default();let servo_player_esp = ServoPlayerEsp::default();center_servo(&servo_esp);// `ServoPlayer` can do everything `Servo` can!center_servo(&servo_player_esp);// and more.run_wave(&servo_player_esp);We call this technique Supertraits. One trait can require another trait, letting you build levels of interfaces without building a class hierarchy. A ServoPlayer is not a subclass of Servo, but every ServoPlayer must also satisfy the Servo contract.So far, the types have been ours to design.

§5 Human · 18%

Next, let’s work with a type that already exists.3. Adding a Method to a Type You Don’t OwnSuppose we want to add is_odd() to usize, Rust’s standard unsigned integer type for sizes and indexes.In object-oriented terms, we want usize to inherit a new method from a new abstract class.Puzzle: How would you do this in Rust?Aside: Do not confuse the words “inherit” and “inherent.” “Inherit,” meaning to pass down, is the thing that Rust mostly does not do, and mostly does not need. “Inherent,” in Rust, means “directly on the type.”Solution: If we try a direct, or inherent, definition of is_odd, the compiler will complain:// impl usize {// fn is_odd(self) -> bool {// self & 1 != 0// }// }// error[E0390]: cannot define inherent `impl` for primitive typesBut we can define our own trait and implement it for usize:trait UsizeExtensions { fn is_odd(self) -> bool;}impl UsizeExtensions for usize { fn is_odd(self) -> bool { self & 1 != 0 }}Now we can use the method as if it were part of usize:let count: usize = 7;assert!(count.is_odd());assert!(!12.is_odd());By convention, we call this an Extension Trait. To the compiler, however, this is just a trait. The convention is useful because it means, “I am adding methods to a type, often a type I do not own.”Aside: Why does the compiler allow you to implement a trait on a type you do not own, but not define direct, inherent methods? Because we are not changing usize itself. We are defining our own trait and saying, “usize knows how to play this role.” Another crate can define a different trait and implement it for usize, but that is a different role.Direct methods are different. If every crate could add methods directly to usize, then many crates would be editing the same method list. Two crates could add different is_odd methods, or Rust itself could add one later. To avoid that mess, Rust only lets the crate that defines a type add direct, inherent methods to it.

§6 Human · 24%

Methods are only part of the story. Sometimes a type needs to behave well in the rest of the Rust ecosystem.4. Giving a Tiny Enum a Full Set of Standard BehaviorsFor a small enum, we often want standard behaviors: a default value, debug printing, equality, ordering, hashing, copying, cloning, and so on.In a class hierarchy, we might draw this as many abstract classes mixed into one concrete type.Puzzle: How would you give LedLevel all these standard behaviors without writing each implementation by hand?Press enter or click to view image in full sizeSolution: In Rust, the standard traits already exist, and derive writes the implementations for us:#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Default)]enum LedLevel { On, #[default] Off,}We use the derived traits like so:let default_level = LedLevel::default();let on = LedLevel::On;let off = LedLevel::Off;assert_eq!(default_level, LedLevel::Off);assert_ne!(on, off);assert!(off > on);let copied = on;let cloned = off.clone();assert_eq!(copied, on);assert_eq!(cloned, off);This is inheritance-like in the sense that LedLevel now participates in many standard behaviors: defaulting, printing, comparing, hashing, copying, and cloning. The Rust mechanism is different from class inheritance, though. The derive attribute asks macros to generate ordinary trait implementations for this concrete type.We call this technique Derive-Generated Implementations. It is one of the most common ways Rust gives a type a bundle of standard behavior.So far, the inheritance-like behavior has come through traits. But sometimes we may want something stronger: “I want this concrete type to inherit from that concrete type.”5. Making a Wrapper Feel Like the Thing Inside ItNow for a different kind of puzzle: what if the thing we want to “inherit from” is not a trait at all, but a concrete type?Suppose we want an HtmlBuffer to feel like a String for ordinary method calls, while still being a distinct type. The catch is that String is not a trait. It is a concrete type with storage.In object-oriented terms, we want one concrete class, HtmlBuffer, to inherit methods from another concrete class, String.