Hacker News new | ask | show | jobs
by kimundi 3500 days ago
Rusts references behave like plain raw C/C++ pointers at runtime, without any bookkeeping code running at all.

The magic all lies in the compiletime borrow checker, which roughly works like this:

    - All data is accessed either through something on the stack or in static memory.
    - Accessing data, say by creating a reference to it, 
      causes the compiler to "borrow" the value for the scope in which the reference
      is alive.
    - The references can be alive for any scope equal or smaller than for which    
      access to the data itself is valid.
    - References track the original scope for which they are alive around as a 
      template-paramter-like thing called "lifetime parameter".
      Note that Rusts use of the word "lifetime" is thus a bit narrower than the
      one used in C++, since it just talks about stack scopes, and not the lifetime 
      of the actual value as would be tracked by a GC or ref counting.
      Example:

      let x = true;
      let r = &x;

      Here, r would infer to a type like `Reference<ScopeOfXVariable, bool>`.
      (The actual type in rust would be a `&'a T` with 
      'a = scope of x, and T = bool).
    - Because the scope is tracked as part of the reference type,
      it is possible to copy/move/transform/wrap references safely, since
      the compiler will always "know" about the original scope and thus can
      check that you never end up in a situation where you accidentally outlive the 
      thing you borrowed, say if you try to return a type that contains a reference 
      somewhere deep down.
    - The borrow itself acts as a compiletime read/write lock on the thing you referenced,
      so for the scope that the reference is alive for the compiler prevents
      you from changing or destroying the referenced thing. Example:

      // This errors:
      let mut a = 5;
      let b = &a;
      a = 10; // ERROR: a is borrowed
      println!("{}", *b);

      // This is fine:
      let mut c = 100;
      { 
          let d = &c;
          println!("{}", *d);
      }
      c = 50;

    - The above examples just use `&` for references, but Rust has two references types:
      - &'a T, called "shared reference", which cause "shared borrows".
      - &'a mut T, called "mutable references", which cause "mutable borrows".
    - Both behave the same in principle, but have different restrictions and guarantees:
      - A mutable borrow is exclusive, meaning no other other borrow to the same data 
        is allowed while the &mut T is alive, but allows you to freely change the T through 
        the reference.
      - A shared borrow may alias, so you can have multiple &T pointing
        to the same data at the same time, but you are not allowed to freely change T through 
        the reference.
      - (If those two cases are too rigid there is also a escape hatch that
        a specific type may opt-into to allow mutation of itself through a shared reference, with 
        exclusivity checked through some other mechanism like runtime borrow counting.)
    - Through these two reference types, Rust libraries can abstract with arbitrary APIs
      without loosing the borrow checker guarantees. Eg, the "reference to vector element"
      example boils down as this:

      let mut v = Vec::new();
      v.push(1);
      let r = &v[0]; // the reference in r now has a shared borrow on v.
      v.push(2);     // push tries to create a mutable borrow of v, which conflicts with the 
                        borrow kept alive by r, so you get a borrow error at compiletime.
      println!("{}", *r);
The important part is that all this is there, per default, for all Rust code in existence, so you can not accidentally ignore it like a library solution you might not know about, or like language features that don't know about the library solutions.
1 comments

Great explanation. Thanks.