Greetings,
Over the thanksgiving break I took some time to write an application
of medium complexity in Rust in order to get a better understanding of
how the language works in practice (up to this point I had never even
installed the compiler, and was merely an interested spectator of the
language's progress).

So I wrote a ray tracer, capable of loading up a restricted set of
.obj model (only triangles, no normals, colours or textures), and then
ray tracing it using a kd-tree for performance. Here's a picture of a
cow, as an example of the output http://i.imgur.com/80E2F.png
(this image took about 6 seconds to render, on a single thread on my
core i7 2600.. although I wouldn't read too much into perf. numbers
for now)

I've attached the code. It's a mixture of quick-n-dirty hacks, and
some attempts at trying out various kinds of modularity. Hardly a
paragon of code quality, but I really don't have time to clean it up.
(the cow model referred to in the source code was downloaded from here
http://groups.csail.mit.edu/graphics/classes/6.837/F03/models/).

I figure I'd write up a quick experience report. The point of this is
for me to point out the rough edges I experienced in the hopes that
they would be considered for future revisions, so it will necessarily
be somewhat negative in tone. This is simply because positive feedback
is less actionable, and not because my overall impression is negative.
And I'm sure you're well aware of positive aspects already (it
would've been part of your design rationale, in most cases). I mention
this in advance in hopes that you won't take the following as any kind
of sweeping criticism, but merely the few things I felt were getting
in my way.

Also, I'm not 100% sure of all of these points. Quite a few of them
might just simply be me not being able to figure out how to do what I
wanted. I was hoping to go back and look into each of them in some
detail to be more certain that there's actually an issue with all of
these things, but it's already been a couple of weeks and I simply
haven't found the time, so I figured I'd write up my concerns such as
they are, and if some of them aren't valid then you can just ignore
them.

Without further ado, here's the summary of things I ran into:

1. Purity, or lack thereof.
Firstly, I found the "pure" keyword went mostly unused. I tried using
it initially, but very quickly ran into places where I couldn't (e.g.
the sqrt function isn't pure, so now my vector length function can't
be either). So after an initial attempt at always defaulting to "pure"
I basically gave up and used it nowhere. That's a shame.

Also, I can't use the pure keyword for functions with local mutable
state. For a language with such imperative feel as Rust, it seems that
disallowing even local mutation would make most functions impure, even
though they're not actually impure in any real sense. Something like
the ST monad in Haskell (which allows you local mutable state, so long
as it doesn't leak), but less in-your-face would be nice. In other
words, I'd like not only local mutable stack variables, but also local
heap allocations/modifications, so long as none of that memory "leaks
out" outside the function.

Second, I'd caution about making purity the exception, rather than the
default. Having the ability to specify that you expect a function to
be pure is great (e.g. for parallelism, or laziness), and it's likely
that this will enforce quite alot of cascading purity throughout your
program. However, I worry that this will happen "after the fact", in
other words that you will ocassionally have critical moments in your
code when some library call requires purity and you have to go back
and modify hundreds of functions because of the cascading
ramifications of making a single function pure. It would be better if
everything was pure by default (assuming that local modifications are
allowed, as mentioned above), with the very few truly impure functions
around being explicitly tagged as such.

This is somewhat fuzzy, but I got the same kind of uneasiness I get in
C where I'm not quite sure that something is pure or not because the
compiler won't enforce it (in Rust's case because I didn't add the
pure keyword, for reasons stated above). It doesn't feel quite as
safe, because calling a function may or may not have subtle hidden
effects, and you can't really rely on the "pure" keyword to make this
distinction, because even things that actually are pure are likely to
have missed adding the flag (see "const" in C++), so you'll still find
yourself wondering "is this actually pure where the author forgot to
mark it as such, or is it impure?".

In general, when asking about what the default behaviour should be, I
feel like there are two criteria to consider:
a) Which is the most common case
b) Which case will cause mistakes to show up faster

If purity worked with local mutable state, I feel that pure-by-default
would win both of these (most of my functions were certainly pure, and
using impurities in a pure function would cause an immediate compiler
error, whereas you could go months forgetting to tag functions with
"pure" without the compiler giving any indication that you're at risk
of a major "purity-cascade" if a future revision needs one of the
toplevel functions to be pure).

2. Mutable locals
I found it annoying that local slots were mutable by default, for a
few reasons. Firstly, it's inconsistent. Rust documentation states
that things are immutable by default, but that doesn't actually apply
to local variables. So it's inconsistent w.r.t. parameters, as well as
w.r.t. record/obj fields. Second, because mutable locals incur a
fairly high tax on readability, IME. This is again due to the fact
that you can't really rely on immutable locals to be marked up as such
(side note: I don't even know how to do this, is it possible?). Again,
witness const in C++; nobody I know bothers tagging local variables as
const. The result is that when reading a new function, you have to
spend considerable effort scanning through the code to find all the
places each variable is modified (even though most of them never are),
in order to understand the data flow.

By the criteria above for choosing a default, immutable-by-default
wins both criteria for my code. Over 90% of my local variables were
actually immutable (though none were tagged as such). And mistakenly
forgetting to tag something as mutable would give an immediate
compiler error, whereas forgetting to tag something as immutable would
go unnoticed until someone in the far future gets annoyed by unclear
data flow and adds the right annotations in bulk (which I do on
occasion in C++, but is a rare occurence!).

3. Ownership of unique-ptr
I find that it's quite common to want to say "this is a pointer to a
heap box that is both uniquely owned, and will never change owner".
E.g. where you really want something conceptually close to just
storing the value "in-line", but need to put it on the heap because
you're referring to yourself recursively (e.g. a tree). Unique
pointers give you a way to say that there's only one owner at a given
time, but I'm not sure if there's a way to make sure ownership is
non-transferable. In fact, this should probably be the default (i.e.
only mutable unique pointers can lose ownership of their memory).

Also, I had a few instance where I wanted to work with unique pointers
in a bit more flexible way than  just passing them to a function, but
I didn't want to copy or take ownership of them. E.g. in kd-tree
tracing you want to traverse the two sub-spaces in different orders
depending on which is closest to the ray origin, the rest of the code
is the same so it's nice to say something like:

let (near,far) = origin < splitter ? (node.left_tree, node.right_tree)
: (node.right_tree, node.left_tree);

And have near and far simply be immutable references to the two
sub-trees. Unfortunately this causes a copy of the unique pointers
(resulting in an order-of-magnitude perf. hit for this application).
There may be some way using explicit types to do what I want here, but
really it seems like copying shouldn't be the default. Perhaps if
locals were immutable (see point 2), they could also be immutable
references by default if the value you assign to it is immutable. I
ended up switching to shared pointers, even though I really would've
preferred to specify unique ownership of the sub-trees in the kd-tree
data structure.

4. I got tripped up by the mandatory literal postfixes about a million
times. This may be just a habit thing.

5. Linear algebra looks really clunky without operator overloading.  I
mean, look at this:
add(scale(n, dot(view_vec, n)*2f), view_vec)

6. I seriously couldn't figure out how to use the pow function. No
amount of importing/using seemed to bring it into scope. :-/


Overall, I found Rust to be quite enjoyable to work with, even though
I'm used to a higher level of tooling support (oh how I miss
intellisense). Look forward to seeing where it goes in the future!


Regards,

-- 
Sebastian Sylvan

Attachment: main.rs
Description: Binary data

Attachment: math3d.rs
Description: Binary data

Attachment: model.rs
Description: Binary data

Attachment: raytracer.rc
Description: Binary data

Attachment: raytracer.rs
Description: Binary data

_______________________________________________
Rust-dev mailing list
Rust-dev@mozilla.org
https://mail.mozilla.org/listinfo/rust-dev

Reply via email to