Rust concepts
The interesting thing about Rust itself is that it’s a strongly typed, memory-safe language, so a lot of things are nailed down hard. It gives up some dynamism and gets a lot of safety and stability back.
Take variables. In Rust a variable is a constant by default, and only the ones you declare specially with let mut are mutable. Then there’s the explicit handling of memory, which you can see in strings:
// this is a pointer to a string view, purely read-only
let s1: &str = "hello";
// this is the actual string object, and it can be modified
let s2: String = String::from("hello");
But the most central concept in Rust is still ownership. Every value has an owner, and a variable takes that ownership at the moment of assignment. On ownership there are two cases. One is the owner handing off its own ownership, and the operation behind that is move, which stands for the switch of owner. The other is copy, where no transfer of ownership happens; it’s an identical duplicate, the original value is still valid, and you have two values and two owners at the same time. From the ownership angle, copy creates a new value outright (along with its owner) and the old one is still there. move doesn’t: the old owner loses ownership. For some objects, getting the copy effect means you have to clone explicitly, otherwise the default is a transfer, and the moment the old variable is assigned to a new one its ownership goes over to the new variable.
Once ownership is clear, the things built on top of it make more sense, like taking and borrowing:
fn consume(s: String) {
println!("{}", s);
}
fn main() {
let a = String::from("hello");
consume(a);
// println!("{}", a); // error: a has already been moved
}
This shows something that looks confusing at first. In TS an object, or a value (which is an object underneath anyway), can be reused by anyone. In Rust any object has to have its ownership settled first, and only whoever holds the permission can do the operations within its scope. s: String here means a transfer of ownership, so s becomes the only owner that can operate on this object, and a holds no permission, so any operation of a’s is invalid too, unless it does a transfer of its own with let a = s, takes ownership over from s, and only then do its later operations become legal.
But one part of this is really hard to swallow. At first glance it looks like a shared object just got made private, so does that mean none of my other functions can use the String object anymore? That’s not it. Ownership is bounded by scope, and once you leave s’s scope (here that’s the inside of the function) its ownership gets dropped. In a new scope, a has to acquire ownership again before it can be used, a: String for instance. The reason a’s operation failed isn’t that s privatized the ownership and crowded everyone else out, it’s that a itself didn’t take ownership before using it.
Picking up from What Rust is good for in AI development, there’s a part that’s easy to mix up, which is the difference between the double colon (::) and the dot (.). Other languages blend these two, but under Rust’s philosophy they have to be kept apart. The first means namespace navigation, indexing into the “structure”, the “thing that exists without a concrete value”, the abstract blueprint in general; unless a constant’s value is predefined right in the blueprint, what you get by default is the member itself. The second is a call on a concrete “value”, the “thing that only exists once there’s a concrete value”, the actual realization of the blueprint.
struct Point {
x: i32,
y: i32,
}
impl Point {
// associated function (no self). By analogy it's a lot like a static method on a class, usable directly without instantiating
fn new(x: i32, y: i32) -> Point {
Point { x, y }
}
// method (has self), which means you need a value before you can use this method
fn distance(&self) -> f64 {
((self.x * self.x + self.y * self.y) as f64).sqrt()
}
}
let p = Point::new(3, 5); // :: takes the associated function new off the type Point ✅
p.x // . takes a field off the instance p ✅
p.distance() // . calls a method on the instance p ✅
Point::x // ❌ illegal, x is a field, not an associated item
One misunderstanding to head off here. The double colon takes members of the type, not the fields inside the type, because only “members” have a path identity and “fields” don’t.
Point::new(3, 5) // new is an associated function, it has a path identity ✅
Point::ORIGIN_Y // ORIGIN_Y is an associated constant, it has a path identity ✅
Point::y // y is a field, no path identity, illegal everywhere ❌
Which brings up another concept along the way. impl looks a lot like a class at first, but it isn’t one. The core point is that Rust bans inheritance at the root, so impl here is only a construct for “attaching methods to a type”, and &self literally points at the type Point itself.
So the “associated function” above, the one that looks like a static method, also looks a lot like a class constructor at first, since new does return a Point instance. It isn’t one. Unlike an ordinary class constructor, which you’re only allowed one of, fn new sits inside the whole impl on completely equal footing with the other methods. Which means you can write as many as you like, and which means it isn’t a “constructor” at all, just an ordinary static (given that Rust has no such thing as a class, this is by analogy) factory method.
As for the other concepts from OOP, Rust does keep some of them. Encapsulation, for one, where the reserved keyword pub designates the interface you want to expose; and unlike other languages where you have to declare private explicitly, properties inside a type are private by default.
mod geometry {
pub struct Point {
x: i32, // private field
pub y: i32, // public field
}
}
// outside the module:
let p = geometry::Point { ... };
p.y // ✅ accessible, because y is pub
p.x // ❌ compile error: x is private, can't be touched from outside the module
trait is a more important concept, directly equivalent to interface in other languages, the interface (contract) idea. It’s an abstract definition of behavioral structure at bottom, and it’s a big part of why DI works smoothly in Rust: once the behavioral structure is defined by the interface, the composition that follows can program against the interface instead of the concrete implementation. But a Rust trait can carry a default implementation itself (which starts to look like a class, though obviously it’s the same as before, a predefined method).
trait Animal {
fn speak(&self) -> String;
fn greet(&self) -> String { // default implementation
format!("我说:{}", self.speak()) // reuses speak, whoever implements Animal gets this greet for free
}
}
And the strongest, most distinctive thing about this seems to be adding an implementation to the standard library, or to any other type that already exists.
trait Describe {
fn describe(&self) -> String;
}
impl Describe for i32 { // adds behavior to the standard library's i32!
fn describe(&self) -> String { format!("我是数字 {}", self) }
}
42.describe() // ✅ "我是数字 42"
Next is that it can clearly separate compile time from run time. This one gets a bit complicated, and it involves how different languages dispatch polymorphism between themselves, that is, whether it’s pinned down at compile time or a vtable gets looked up at run time to work out which concrete function this generic points at. Most languages handle this kind of generic passed in through an interface with dynamic dispatch by default, and you don’t get to choose, so there’s a lookup cost and a runtime mixing problem. Rust makes the distinction explicit with T versus dyn, or rather, by designing two roads, “generics and trait objects”, it makes both of them visible. That in itself is Rust’s design philosophy showing up again, the one about “making the implicit explicit”. Whether it’s the memory control expressed through ownership or explicit boundaries, this is just one more instance of it.
// static dispatch (compile-time polymorphism): generics + trait bound
// T here ends up permanently monomorphized, i.e. whatever is fixed at compile time is what it stays, otherwise you get an error; it isn't left for run time to decide
fn make_speak<T: Animal>(a: T) -> String {
a.speak() // T is any type that implements Animal, pinned at compile time, zero cost
}
// dynamic dispatch (runtime polymorphism): trait object. & means a reference, a pointer to a pointer to a
// this gets decided at run time, and what dyn Animal is here depends on what actually gets passed in at run time
fn make_speak_dyn(a: &dyn Animal) -> String {
a.speak() // only at run time do you know whether it's a Dog or a Cat, look up the vtable
}
// note that vec![...] is only a list construction for the demo, simulating several types passed in at run time
let zoo: Vec<Box<dyn Animal>> = vec![Box::new(Dog), Box::new(Cat)];
for animal in &zoo {
println!("{}", animal.speak()); // same loop, Dog and Cat each say their own thing -- classic polymorphism
}
Then there’s the point where trait differs most from interface, which is that you can write it empty.
// Send / Sync roughly look like this (simplified):
trait Send {} // empty! no methods at all
trait Sync {}
Send and Sync here, or really an empty trait like this in general, are a marker that propagates automatically down the chain, and they can stamp a label on the behavior they belong to at compile time. That label lets the author control and declare particular operations explicitly, so the places where an operation shouldn’t happen get blocked at compile time. Take Rc in the standard library. Its purpose is to build an index, letting one piece of data be shared by several owners, and it keeps an owner counter inside, and modifying that thing has a concurrency race problem. That’s where the Send label earns its keep.
The author of Rc only has to declare explicitly that this thing is !Send, and thread::spawn on the cross-thread side plays along by requiring that only operations carrying the Send label get through. With that kind of agreement across methods, dangerous operations get stopped at the compiler instead of surfacing at run time. Generalized, this can be very flexible: concurrency, copying, anything where you can attach a label on one side and set a gate on the other can use a similar mechanism. Operations that don’t meet the gate fail to compile outright, so you’re never left finding out at run time. Once one side sets the label, the child objects that implement this interface all get it propagated to them automatically. That property is specific to Send / Sync, it’s called an auto trait, and it propagates to every consumer.
That automatic propagation is a Rust specialty, other languages don’t have it, but the other custom empty-shell labels you write yourself don’t get the propagation. Say I wrote one, but later forgot to set up a ticket checker or to stamp the downstream, then the whole thing is void. In other words, how well this label mechanism works depends on the author’s design and on how well he controls the boundaries of his own code. With no design at all, the problems that were going to happen still happen. But then where’s Rust’s extra advantage? The core of it is the compile-time guarantee. Once a Rust empty trait is on, if it isn’t there later then it isn’t there, straight to an error, whereas in something like Java the problem can only surface at run time.
// ① define the label (empty shell)
trait A {}
// ② stamp some type with it
struct Dog;
impl A for Dog {} // fits A onto Dog
struct Cat;
// Cat doesn't get stamped
// ③ downstream: "check the ticket" in the function signature -- <T: A> is the gate
fn need_ticket<T: A>(x: T) {
// any x that gets into this function body necessarily has a type carrying A. The compiler has guaranteed it for you.
}
// ④ the consumer tries
need_ticket(Dog); // ✅ Dog has A, gets through
need_ticket(Cat); // ❌ Cat has no A, compile error: the trait `A` is not implemented for `Cat`
fn need_ticket<T: A>(x: T) {} // form 1: mark it right on the generic parameter
fn need_ticket<T>(x: T) where T: A {} // form 2: where clause, cleaner for complicated bounds
fn need_ticket(x: impl A) {} // form 3: impl Trait syntax, the most compact
Rust’s advantage over TS here is that this label can’t be papered over downstream with an as assertion. If you insist on doing it you have to declare it explicitly, pin that object’s type onto the label, and the cost is plain: it means everyone downstream knows you added this label (it’s empty, so it doesn’t affect behavior, but the stamp is right there and it’s hard to sneak past anyone). An as assertion is only a temporary claim, and it doesn’t fundamentally change the original object’s type. The other thing is that in Rust the subject of A {} is A, while in TS the subject is {}. A TS type answers to the structure, which is to say an empty {} structure can correspond to any object at all, and that isn’t the semantics of a label.
Translation note. I wrote this in Chinese. This English version is an LLM translation, so the wording is not mine even though the thinking is. Original: rust 概念.