60sec Review: Rust Language
Lately I've been digging into Rust, a new programming language sponsored by Mozilla. They recently rewrote their docs and announced a roadmap to 1.0 by the end of the year, so now is a good time to take a look at it. I went through the new Language Guide last night then wrote a small ray tracer to test it out.
One of the biggest success stories of the last three decades of programming is memory safety. C and C++ may be fast but it's very easy to have dangling pointers and buffer overflows. Endless security exploits come back to this fundamental limitation of C and C++. Raw pointers == lack of memory safety.
Many modern languages provide this memory safety, but they do it at runtime with references and garbage collection. JavaScript, Java, Ruby, Python, and Perl all fall into this camp. They accomplish this safety at the cost of runtime speed. While they are typically JITed today instead interpreted, they are all slower than C/C++ because of their runtime overhead. For many tasks this is fine, but if you are building something low level or where speed really matters, then you probably go back to C/C++ and all of the problems it entails.
Rust is different. Rust is a statically typed compiled language meant to target the same tasks that you might use C or C++ for today, but it's whole purpose in life is to promote memory safety. By design, Rust code can't have dangling pointers, buffer overflows, or a whole host of other memory errors. Any code which would cause this literally can't be compiled. The language doesn't allow it. I know it sounds crazy, but it really does work.
Most importantly, Rust achieves all of these memory safety guarantees at compile time. There is no runtime overhead, making the final code as fast as C/C++, but far safer.
I won't go into how all of this work, but the short description is that Rust uses several kinds of pointers that let the compiler prove who owns memory at any given moment. If you write up a situation where the complier can't predict what will happen, it won't compile. If you can get your code to compile then you are guaranteed to be memory safe.
I plan to do all of my native coding in Rust when it hits 1.0. Rust has a robust FFI so interfacing with existing C libs is quite easy. Since I absolutely hate C++, this is a big win for me. :)
Coming into Rust I was worried the pointer constraints would make writing code difficult, much like puzzle languages. I was pleasantly surprised to find it pretty easy to code in. It's certainly more verbose than a dynamic language like JavaScript, but I was able to convert a JS ray tracer to Rust in about an hour. The resulting code roughly looks like what you'd expect from C, just with a few differences. Let's take a look.
First, the basic type definitions. I created a Vector, Sphere, Color, Ray, and Light class. Rust doesn't really have classes in the C++/Java sense, but it does have structs enhanced with method implementations, so you can think of them similar to classes.
use std::num; struct Vector { x:f32, y:f32, z:f32 } impl Vector { fn new(x:f32,y:f32,z:f32) -> Vector { Vector { x:x, y:y, z:z } } fn scale(&self, s:f32) -> Vector { Vector { x:self.x*s, y:self.y*s, z:self.z*s } } fn plus(&self, b:Vector) -> Vector { Vector::new(self.x+b.x, self.y+b.y, self.z+b.z) } fn minus(&self, b:Vector) -> Vector { Vector::new(self.x-b.x, self.y-b.y, self.z-b.z) } fn dot(&self, b:Vector) -> f32 { self.x*b.x + self.y*b.y + self.z*b.z } fn magnitude(&self) -> f32 { (self.dot(*self)).sqrt() } fn normalize(&self) -> Vector { self.scale(1.0/self.magnitude()) } } struct Ray { orig:Vector, dir:Vector, } struct Color { r:f32, g:f32, b:f32, } impl Color { fn scale (&self, s:f32) -> Color { Color { r: self.r*s, g:self.g*s, b:self.b*s } } fn plus (&self, b:Color) -> Color { Color { r: self.r + b.r, g: self.g + b.g, b: self.b + b.b } } } struct Sphere { center:Vector, radius:f32, color: Color, } impl Sphere { fn get_normal(&self, pt:Vector) -> Vector { return pt.minus(self.center).normalize(); } } struct Light { position: Vector, color: Color, }
Without knowing the language you can still figure out what's going on. Types are specified after the field names, with f32
and i32
meaning integer and floating point values. There's also a slew of finer grained number types for when you need tight memory control.
Next up I created a few constants.
static WHITE:Color = Color { r:1.0, g:1.0, b:1.0}; static RED:Color = Color { r:1.0, g:0.0, b:0.0}; static GREEN:Color = Color { r:0.0, g:1.0, b:0.0}; static BLUE:Color = Color { r:0.0, g:0.0, b:1.0}; static LIGHT1:Light = Light { position: Vector { x: 0.7, y: -1.0, z: 1.7} , color: WHITE };
Now in my main
function I'll set up the scene and create a lookup table of one letter strings for text mode rendering.
fn main() { println!("Hello, worlds!"); let lut = vec!(".","-","+","*","X","M"); let w = 20*4i; let h = 10*4i; let scene = vec!( Sphere{ center: Vector::new(-1.0, 0.0, 3.0), radius: 0.3, color: RED }, Sphere{ center: Vector::new( 0.0, 0.0, 3.0), radius: 0.8, color: GREEN }, Sphere{ center: Vector::new( 1.0, 0.0, 3.0), radius: 0.3, color: BLUE } );
Now lets get to the core ray tracing loop. This looks at every pixel to see if it's ray intersects with the spheres in the scene. It should be mostly understandable, but you'll start to see the differences with C.
for j in range(0,h) { println!("--"); for i in range(0,w) { //let tMax = 10000f32; let fw:f32 = w as f32; let fi:f32 = i as f32; let fj:f32 = j as f32; let fh:f32 = h as f32; let ray = Ray { orig: Vector::new(0.0,0.0,0.0), dir: Vector::new((fi-fw/2.0)/fw, (fj-fh/2.0)/fh,1.0).normalize(), }; let mut objHitObj:Option<(Sphere,f32)> = None; for obj in scene.iter() { let ret = intersect_sphere(ray, obj.center, obj.radius); if ret.hit { objHitObj = Some((*obj,ret.tval)); } }
The for
loops are done with a range
function which returns an iterator. Iterators are used extensively in Rust because they are inherently safer than direct indexing.
Notice the objHitObj
variable. It is set based on the result of the intersection test. In JavaScript I used several variables to track if an object had been hit, and to hold the hit object and hit distance if it did intersect. In Rust you are encouraged to use options. An Option
is a special enum with two possible values: None
and Some
. If it is None
then there is nothing inside the option. If it is Some
then you can safely grab the contained object. Options are a safer alternative to null pointer checks.
Options can hold any object thanks to Rust's generics. In the code above I tried out something tricky and surprisingly it worked. Since I need to store several values I created an option holding a tuple, which is like a fixed size array with fixed types. objHitObj
is defined as an option holding a tuple of a Sphere
and an f32
value. When I check if ret.hit
is true I set the option to Some((*obj,ret.tval))
, meaning the contents of my object pointer and the hit distance.
Now lets look at the second part of the loop, once ray intersection is done.
let pixel = match objHitObj { Some((obj,tval)) => lut[shade_pixel(ray,obj,tval)], None => " " }; print!("{}",pixel); } }
Finally I can check and retrieve the option values using an if
statement or a match
. Match is like a switch or case statement in C, but with super powers. It forces you to account for all possible code paths. This ensures there are no mistakes during compilation. In the code above I match the some and none cases. In the Some
case it pulls out the nested objects and gives them the names obj
and tval
, just like the tuple I stuffed into it earlier. This is called destructuring in Rust. If there is a value then it calls shadepixel
and returns character in the look up table representing that grayscale value. If the None
case happens then it returns a space. In either case we know the pixel
variable will have a valid value after the match. It's impossible for pixel
to be null, so I can safely print it.
The rest of my code is basically vector math. It looks almost identical to the same code in JavaScript, just strongly typed.
fn shade_pixel(ray:Ray, obj:Sphere, tval:f32) -> uint { let pi = ray.orig.plus(ray.dir.scale(tval)); let color = diffuse_shading(pi, obj, LIGHT1); let col = (color.r + color.g + color.b) / 3.0; (col * 6.0) as uint } struct HitPoint { hit:bool, tval:f32, } fn intersect_sphere(ray:Ray, center:Vector, radius:f32) -> HitPoint { let l = center.minus(ray.orig); let tca = l.dot(ray.dir); if tca < 0.0 { return HitPoint { hit:false, tval:-1.0 }; } let d2 = l.dot(l) - tca*tca; let r2 = radius*radius; if d2 > r2 { return HitPoint { hit: false, tval:-1.0 }; } let thc = (r2-d2).sqrt(); let t0 = tca-thc; //let t1 = tca+thc; if t0 > 10000.0 { return HitPoint { hit: false, tval: -1.0 }; } return HitPoint { hit: true, tval: t0} } fn clamp(x:f32,a:f32,b:f32) -> f32{ if x < a { return a; } if x > b { return b; } return x; } fn diffuse_shading(pi:Vector, obj:Sphere, light:Light) -> Color{ let n = obj.get_normal(pi); let lam1 = light.position.minus(pi).normalize().dot(n); let lam2 = clamp(lam1,0.0,1.0); light.color.scale(lam2*0.5).plus(obj.color.scale(0.3)) }
That's it. Here's the final result.
So far I'm really happy with Rust. It has some rough edges they are still working on, but I love the direction they are going. It really could be a replacement for C/C++ in lots of cases.
Buy or no buy? Buy! It's free!
Posted September 17th, 2014
Tagged: programming bookreview