Sized means, why it is an invisible bound on every generic you have ever written, and how ?Sized relaxes it;Send and Sync actually guarantee about moving and sharing values across threads;Send and Sync are auto traits the compiler derives from a type's fields, not something you implement by hand;Rc is refused across threads while Arc is welcomed, tying straight back to the concurrency episode;Copy, which you met when we derived traits, is a marker too, and why its mere presence flips assignment from a move into a copy.Learn Rust Series):At the end of last episode I promised we would finally look, head on, at "traits that carry no methods at all, that exist purely as promises the compiler reads about a type: can it be copied bit for bit, can it be sent between threads, does it even have a known size". I called them the load-bearing walls behind an awful lot of what you have been doing since episode 3. Today we knock on those walls. There are four that matter above all the rest -- Sized, Send, Sync and Copy -- and the striking thing about every one of them is that they have no methods. Nothing to call, nothing to override. They exist so the type can wear a label, and the compiler reads that label to decide what your code is allowed to do.
That is what a marker trait is: a trait whose entire purpose is the fact of implementing it. Most traits we have met so far are about behaviour -- Display gives you a way to format, Iterator gives you a next, From gives you a conversion. A marker trait gives you nothing to call; it simply asserts a property. And because Rust's whole design is about pushing guarantees to compile time, these four little empty traits end up doing an enormous amount of load-bearing work. You have leaned on all four already, mostly without noticing. By the end of today they will be explicit, and a few error messages that used to look like magic will suddenly read like plain English. Let me clear last episode's homework first, as always, and then we go marker by marker ;-)
Episode 24 was blanket impls and the newtype pattern. Three exercises, and here is each one with full, runnable code.
Exercise 1 asked you to write a blanket impl impl<T: std::fmt::Debug> PrettyPrint for T, where PrettyPrint is your own trait with a method pretty(&self) -> String that returns the value formatted with {:?} inside square brackets, then to call it on an integer, a string slice and a Vec<i32> to confirm one impl covered all three:
use std::fmt::Debug;
trait PrettyPrint {
fn pretty(&self) -> String;
}
impl<T: Debug> PrettyPrint for T {
fn pretty(&self) -> String {
format!("[{self:?}]")
}
}
fn main() {
println!("{}", 42.pretty()); // [42]
println!("{}", "hi".pretty()); // ["hi"]
println!("{}", vec![1, 2, 3].pretty()); // [[1, 2, 3]]
}
One impl, three completely different types lighting up with a .pretty() method, exactly the blanket-impl leverage we spent last episode on. Note the nested brackets on the Vec: {:?} already prints a vector as [1, 2, 3], and our own square brackets wrap around that, giving [[1, 2, 3]]. The &str prints with its quotes because that is what Debug does for strings -- a small reminder that Debug is the programmer-facing format, not the pretty one.
Exercise 2 wanted a Meters(f64) newtype and a separate Feet(f64) newtype, plus a function describe(m: Meters) -> String, proving that passing a Feet to describe is a compile error while a Meters works:
struct Meters(f64);
struct Feet(f64);
fn describe(m: Meters) -> String {
format!("{} metres", m.0)
}
fn main() {
let distance = Meters(5.0);
println!("{}", describe(distance)); // 5 metres
// describe(Feet(5.0)); // compile ERROR: expected Meters, found Feet
let _ = Feet(5.0);
}
Both are a bare f64 at runtime, but the type checker treats them as unrelated. That commented-out line does not compile, and that is the entire point -- the type safety case from last episode, where two values with identical representation are kept apart so you cannot accidentally pass metres where feet were meant.
Exercise 3 was the chewy one: a Temperature newtype wrapping f64 whose constructor returns Option<Temperature> and rejects anything below -273.15 (absolute zero), plus a Deref to f64 so you can call .round() directly, then a comment reasoning about whether exposing all of f64 is wise here:
use std::ops::Deref;
struct Temperature(f64);
impl Temperature {
fn new(celsius: f64) -> Option<Temperature> {
if celsius < -273.15 {
None
} else {
Some(Temperature(celsius))
}
}
}
impl Deref for Temperature {
type Target = f64;
fn deref(&self) -> &f64 {
&self.0
}
}
fn main() {
let t = Temperature::new(21.6).unwrap();
println!("{}", t.round()); // 22, via Deref to f64::round
println!("below zero? {}", Temperature::new(-300.0).is_none()); // true
// Reasoning: Deref here is a mixed blessing. `.round()` is harmless, but
// Deref also exposes arithmetic, so `*a + *b` on two Temperatures silently
// produces a bare f64 with no bound check -- the "cannot go below -273.15"
// invariant does NOT survive arithmetic. For a strict invariant type, expose
// only the few blessed methods in stead of Deref-ing the whole f64.
}
The invariant (never below absolute zero) is guaranteed at construction, but Deref punches a hole in the wrapper: once you can reach all of f64, nothing stops arithmetic from producing an out-of-range value that skips new. So the honest answer to the exercise is "convenient, but leaky" -- which is precisely the judgement call I flagged last episode about restrict-versus-label newtypes. Right, homework cleared -- now the markers ;-)
We start with the marker you have used the most and seen the least: Sized. A type is Sized when its size in bytes is known at compile time. That is almost everything you have ever written -- an i32 is 4 bytes, a bool is 1, a struct Point { x: i32, y: i32 } is 8, a Vec<T> is three machine words regardless of how many elements it holds. The compiler knows all of these before the program runs, so it can lay out stack frames, pass values in registers, and generally do its job.
A handful of types are not Sized, and they are worth naming: the string slice str (not &str, but the bare str behind it), the array slice [T], and trait objects dyn Trait from episode 15. Their size depends on runtime data -- a str might be five bytes or five million -- so the compiler cannot know it in advance. These are called dynamically sized types, or DSTs, and you can only ever handle them behind a pointer like &str, Box<str> or &[T], where the pointer has a known size even though the data does not.
Here is the twist that makes Sized special among all traits: every generic parameter has an implicit T: Sized bound, automatically, invisibly, whether you asked for it or not. When you write fn f<T>(x: T), Rust silently reads it as fn f<T: Sized>(x: T). That is why generics "just work" for normal types -- they are quietly required to be sized. To opt out and accept an unsized type, you write the special relaxed bound ?Sized and take the value behind a reference:
use std::fmt::Debug;
fn show<T: ?Sized + Debug>(value: &T) {
println!("{value:?}");
}
fn main() {
show("a string slice"); // str is NOT Sized
show(&[1, 2, 3][..]); // [i32] slice is NOT Sized
show(&42); // i32 IS Sized, works too
}
Read ?Sized as "the Sized requirement is relaxed here -- T might not be sized, so I will only touch it through a reference". Without that ?Sized, the call show("a string slice") would fail, because the string slice type is str, which is unsized, and the invisible T: Sized bound would reject it. You need ?Sized whenever you want a single generic function to work uniformly over str, slices, and trait objects. For ordinary day-to-day code you will never type Sized yourself; it is one of those markers that is simply there, holding up the floor while you walk on it.
Now the two markers at the heart of Rust's "fearless concurrency" slogan from episode 13. First, Send. A type is Send if it is safe to transfer ownership of a value of that type to another thread. Almost every type is Send -- integers, String, Vec, your own structs made of Send fields. The famous exceptions are Rc (episode 12) and raw pointers, and we will see exactly why in a moment.
Where you actually meet Send is the moment you spawn a thread. The closure you hand to thread::spawn, and everything it captures by move, must be Send, because those captured values are physically travelling from one thread to another:
use std::thread;
fn run_in_thread<F: FnOnce() + Send + 'static>(f: F) {
thread::spawn(f).join().unwrap();
}
fn main() {
let data = vec![1, 2, 3];
run_in_thread(move || {
println!("moved to a thread: {data:?}"); // Vec is Send
});
}
That Send + 'static bound is not noise -- it is the compiler spelling out its demand: "whatever you move into this thread must be safe to send across the boundary (Send), and must not borrow anything that could be destroyed while the thread is still running ('static)". A Vec<i32> satisfies both: it owns its buffer outright, so moving it to another thread hands over sole ownership cleanly, and it borrows nothing. If you have ever wondered where the phrase "the trait Send is not implemented for..." in a threading error comes from, this is the machinery underneath it.
The companion marker is Sync, and it is about sharing rather than moving. A type T is Sync if it is safe for multiple threads to hold a shared reference &T at the same time. The formal definition is beautifully compact: T is Sync exactly when &T is Send. In other words, sharing a T across threads is safe precisely when the reference to it can be sent across threads. Read that twice -- it collapses "shared across threads" neatly into "the reference is sendable".
The type that makes this concrete is Arc<T> from episode 13, the atomically reference-counted pointer. Arc<T> is both Send and Sync whenever T is, and that is exactly what lets many threads share one value:
use std::sync::Arc;
use std::thread;
fn main() {
let shared = Arc::new(vec![10, 20, 30]);
let mut handles = Vec::new();
for id in 0..3 {
let s = Arc::clone(&shared); // Arc is Send + Sync
handles.push(thread::spawn(move || {
println!("thread {id} sees {:?}", s);
}));
}
for h in handles {
h.join().unwrap();
}
}
Each thread gets its own Arc clone, and every clone points at the same underlying vector. Handing them out to three threads is safe because Arc<Vec<i32>> is Sync -- the compiler has proven, at compile time, that simultaneous shared access here cannot cause a data race. No lock needed in this read-only example, no runtime check, no hoping-it-works. The proof is baked into the type before the program ever runs.
Here is the part that ties a bow on the whole episode. Send and Sync are auto traits: you almost never implement them by hand. Instead the compiler derives them for you automatically, structurally, by a simple rule -- a type is Send if all of its fields are Send, and Sync if all of its fields are Sync. Put one non-Send thing inside a struct and the whole struct stops being Send, no matter how big and innocent the rest of it looks. The property propagates upward through composition, silently, for every type in your program.
That single rule is what makes Rc uncrossable. Rc<T> deliberately does not implement Send or Sync, because its reference count is a plain non-atomic integer -- two threads incrementing it at once would corrupt the count and cause a use-after-free or a double-free. So the language marks Rc as neither Send nor Sync, and that mark ripples out to everything containing an Rc. You can watch the compiler make its decision by asking it to prove Send with a tiny helper:
use std::rc::Rc;
use std::sync::Arc;
fn assert_send<T: Send>(_value: &T) {}
fn main() {
let arc = Arc::new(5);
assert_send(&arc); // Arc IS Send: this compiles
let rc = Rc::new(5);
// assert_send(&rc); // would NOT compile: `Rc` cannot be sent between threads
let _ = rc;
}
Uncomment that middle line and the build fails with a message about Rc<i32> not being Send. And the beauty is what happens one level up: if you tuck an Rc inside your own struct, your struct inherits the non-Send-ness for free, and thread::spawn will refuse it with the same error pointing at the Rc buried inside. You do not have to remember which types are thread-safe -- the compiler tracks it through every layer of composition and stops you at the door. That is the whole "fearless" in fearless concurrency: the fear has been converted into a compile error.
The last of the four is one you met back in episode 22 when we derived traits, and I want to name it explicitly as a marker now: Copy. Like the others, Copy has no methods. Its presence is a pure signal to the compiler, and the signal is "a value of this type can be duplicated by copying its bits, so treat assignment as a copy in stead of a move":
#[derive(Clone, Copy)]
struct Point {
x: i32,
y: i32,
}
fn main() {
let a = Point { x: 1, y: 2 };
let b = a; // Copy marker: this COPIES, a stays valid
println!("{} {}", a.x, b.x); // 1 1 -- both usable
}
Compare that to a type without Copy, like String. There, let b = a; moves a into b, and a becomes invalid -- touch it afterward and you get the classic "value borrowed here after move" error we first hit in episode 3. The only difference between the two behaviours is the presence of the Copy marker. No method is called; the compiler simply reads the mark and duplicates the bytes on assignment in stead of transferring ownership. Copy requires Clone as a supertrait (which is why we derive both together), but the two do different jobs: Clone gives you an explicit, possibly-expensive .clone() you call by hand, while Copy is the invisible, always-cheap, bit-for-bit duplication the compiler does implicitly. You only get Copy for types that are truly bit-copyable -- put a String field in Point and the derive stops compiling, because a String owns a heap buffer that cannot be duplicated by a dumb byte copy.
All four markers work the same way, then: Sized, Send, Sync and Copy are invisible facts stapled to a type, and the compiler acts on them without ever calling a method. They are the quiet backbone of Rust's compile-time safety.
Since quite some of you came through the Learn Python Series first (as did I, having taught Python for years), a look sideways is worth it -- because this is an area where Rust is genuinely doing something most languages simply do not.
Python has no Send or Sync at all. You can share any object between threads, and whether that is safe is not the type system's problem -- it is yours. The Global Interpreter Lock (the GIL) papers over a lot of it by only letting one thread run bytecode at a time, but the moment you reach for real parallelism (multiprocessing, C extensions, the newer free-threaded builds) you are back to hoping your data structures are used safely, with no compiler watching:
import threading
counter = 0 # shared, mutable, and nothing stops a data race
def bump():
global counter
for _ in range(100_000):
counter += 1 # not atomic; two threads here can lose updates
threads = [threading.Thread(target=bump) for _ in range(4)]
for t in threads: t.start()
for t in threads: t.join()
print(counter) # often NOT 400000 -- a silent data race
That program compiles and runs, and prints the wrong number some of the time, and Python never says a word. Go improves on this with goroutines and channels, but its safety is still a runtime affair: the language ships a race detector you opt into (go run -race), because the type system itself will happily let two goroutines scribble on the same map and only blow up when you get unlucky at runtime. Rust puts the same guarantee where I want it -- at compile time. Send and Sync mean the equivalent broken program does not build. Three points on a line again, the shape we keep seeing: Python maximally flexible and unchecked, Go pragmatic but checked only at runtime, Rust strict and checked before the program ever starts. I know which one I want when a data race would cost real money ;-)
Sized, Send, Sync and Copy.Sized means the type's size is known at compile time. Every generic has an invisible T: Sized bound; you relax it with ?Sized (taking the value behind a reference) to handle str, slices and dyn Trait.Send means a value is safe to move to another thread; Sync means a &T is safe to share across threads, and T: Sync holds exactly when &T: Send.Send and Sync are auto traits: the compiler derives them structurally from a type's fields. One non-Send field (like an Rc) makes the whole type non-Send, which is precisely why Rc is refused across threads and Arc is accepted.Copy is a marker that flips assignment from a move into a bit-for-bit copy. It needs Clone as a supertrait, and you only get it for types with no heap-owning fields.The bigger thread here is that Rust keeps moving guarantees to compile time by encoding them as facts about types -- sometimes as behaviour-bearing traits, sometimes as these empty markers. Next we push on that idea from a different angle entirely: what if a type could carry not just a property, but an actual value baked into it -- an array whose length is part of its type, checked before the program runs? That is where the type system starts to blur the line between values and types, and it is a genuinely mind-bending corner of Rust once you see it. One thing at a time ;-)
Three exercises as always, gentle to chewier. Full solutions open the next episode, so genuinely have a go first -- typing it yourself is where it sticks.
fn announce<T: ?Sized + std::fmt::Display>(value: &T) that prints the value, and call it with a str literal, a String, and an i32. Then remove the ?Sized and observe which call stops compiling, and read the error.fn assert_sync<T: Sync>(_v: &T) {} and confirm that an Arc<i32> passes it. Then define a struct struct Shared { data: std::rc::Rc<i32> } and try to pass an &Shared to assert_sync -- read the error and note which field is named as the culprit.#[derive(Clone, Copy)] on a small struct made only of integer fields and confirm assignment copies (the original stays usable). Then add a String field to the same struct and watch the Copy derive stop compiling -- explain in a comment why String blocks Copy.De groeten, and thanks for reading! ;-)