01: Memory and Iteration

·9min·Me.

When programming, there is often an accepted or normal method or approach to a problem. This is good, it allows a problem space to have "design patterns" or "strategies" which solve the problem in an elegant way. It takes time (years?) to learn good patterns for different scenarios - particularly for more niche topics like high-performance code.

This post explores the difference in approach and thinking between how to export "data" of some sort, from one entity inside a program to another entity. Once a baseline is described with C code examples and "C thinking", a Rust solution to the same problem is introduced. Hopefully a comparison between the two shows the difference in mentality and refactorabiliy of the final solution!

The Problem Description

Imagine we have a program that monitors some aspect of a computer. It measures some kind of value, but not just one, lets say 16 of them. The range of the value is [0..1], so a 32-bit float is the ideal datatype. Now this entity wants to tell another entity about those values, how do we do it in the "normal" C way? (There's actually two common methods, but they are very similar!)

Memory Based Solutions

The following solutions in C, C++ and Rust all use a "memory-thinking" type solution. To put it differently, the implementation itself is centered around memory, and how different entities read or write that same chunk of memory. While it works it is not usually the best approach long-term.

C: Exposed Memory Approach

This approach gives the user of the API access to internal state of the entity, allowing the user of the API to directly read otherwise "abstracted away" implementation details.

struct entity {};
// Call the function, retrieves a "vals" pointer and a "valid" length
// to iterate to... and hope the user doesn't over-read!
int outvar_len = 0;
float* vals = get_values(struct entity* self, int *outvar_len);

for (int i = 0; i < outvar_len; i++) {
  printf("%i: %f\n", i, vals[i]);
}

C: Give Me Memory Approach

struct entity {};
// 1) Call this function with NULL as values pointer,
//    and it will return the number items required in the array
// 2) On return, the pointer to "values" has been filled in if len
//    is large enough for all values, and the actual return value is <= len
int get_values(struct entity* self, float *values, int len);

So you'll notice there's "raw pointers" being used, manual length checks, and more required for correct usage of this API, that's normal for C code.

C++: Return of the Vector Approach

C++ folks will say ah, we could return a std::vector, and avoid manual pointers!

// wooo look, no raw pointers!
std::vector<float> Entity::get_values();

Agreed, that causes an allocation to take place. Perhaps we should pass a reference to a vector, allowing the user to figure-out allocation/ownership of the vector, allowing re-use of a pre-exist std::vector<float> if it exists, or just have the user allocate a new one and use it if they don't care for performance? Yep - would all work, all reasonable solutions.

// return value to indicate success... and hope the user checks it?
int Entity::get_values(std::vector<float> &values);

OK, perhaps a bit better, but the user still has to alloc and manage memory, and the program always has to copy the values into the provided vector (stores from the CPU micro-architecture). So although this works, and is a bit better, its not ideal as the user can forget to check return value, and potentially iterate an array of 0 items in length, thinking the correct thing happend.

Rust: Refer to my Memory Slice

Ok, so being a Rust blog, you're expecting me to say "Rust will be better"! Well, not really. I mean yes and no, because fundamentally the memory-way is just bad. Let me explain with code

fn get_values(&self) -> &[f32];

Good API? Well, the slice gives a nice way of exposing some data, with a length included, and a reference so its "lifetime" isn't exceeded. That avoids bugs, nice one! Adding a Result<> over the return value makes it fallible, and the compiler ensures that the user is somehow handling errors (.unwrap()-ing them.. oh well, thier code!)

fn get_values(&self) -> Result<&[f32], Error>;

This works just fine until...

A Wild Refactor Appears

So is the above API "nice"? Its "OK" yes, but not nice. Why? One refactor later, you want to change the internal storage from an array of [f32; N] to a HashMap. Well... you have to provide the caller of get_values() with the &[f32] that you promised... so, that sucks.

How to avoid this scenario where they memory layout is not exposed to the consumer of the data? Fundamentally, exposing the memory layout is not good - we want to communicate a measurement of some sort, we do not want to tie down an implementation detail about how that measurement is being stored inside the implementation itself.

tip
Advanced Readers

It gets even worse when you have a trait fn being implemented by multiple structs. The trait has to define the memory layout for the impls in the function return type, and it has no idea how the implementations might store the data internally!

Code Based Solutions

So if exposing memory isn't a nice solution, what is better? Well, we can consider providing a function which executes code (a number of times) in order to communicate the values. Interestingly, this is NOT tied to Rust at all - its just that Rust promotes these concepts much more than C.

C: Call Me (with a) Value

So how would that actually be implemented? Function pointers and "advanced" things like that of course. If you think this is crazy, just look at the QSort example on wikipedia.

// signature of a function the user must implement, to handle "value"
void handle_value_func(float value, void *userdata) {
  struct entity *e = userdata; // manually casting stuff, wooo!
  printf("%f\n", i, vals[i]);
}

// Calling this function will invoke the above function N times,
// where N is the total number of values to be processed.
int get_values(struct entity* self, handle_value_func);

Ok, so this isn't totally crazy, but its not much of an improvement either. The user is now responsible for implementing a function which handles the value, and has to do some casting of the self struct through a void* in order to have the get_values() not need to know what type the user's datastructure has... ugh. This is the best in land of C (... some would reach to macros, but I wont!)

C++: Lambdas and Stuff

There is a similar implementation to the above C version. Look at the C++ QSort example if you want to see generally what it would be like. While its not needed to do the manual void* casting, I'm not liking the syntax much either. It probably is a better solution than the std::vector<float> stuff above though!

Rust: The Iterator Iterates Again

In Rust, thinking is usually in terms of functionality as provided by a specific trait. And in this case, that applies 100%: the Iterator trait A related trait is the IntoIterator which describes the functionality of something being able to turn itself into a thing that can be iterated.

What if the fn we want to give the user returns an IntoIterator<Item = f32>...

struct Entity {
    data: Vec<f32>,
}

impl Entity {
    fn get_values(&self) -> Result<&[f32], std::io::Error> {
        Ok(&self.data)
    }
}

impl IntoIterator for Entity {
    // this states the type of data produced by it iterator
    type Item = f32;
    // this states the type of the iterator itself (and its contents inside the <>)
    type IntoIter = std::vec::IntoIter<f32>;
    fn into_iter(self) -> Self::IntoIter {
      // here we piggy-back on the Vec::IntoIter trait impl
      self.data.into_iter()
    }
}

The function (or trait that it is part of) now does not specify the memory storage/layout, it only provides the generic concept of "we can iterate across these values for you".

This function is not demanding how you store values in memory, by using Iterator as the return type, and a more "code thinking" approach, the user to access each value just as is required.

Conclusions

The "memory based thinking" is most commonly done in C, as the callbacks and void* casting requires more coding "up-front". Exporting a pointer and using it is fast & easy... and easy to get wrong.

Demanding a specific memory layout (like an array, std::vector, or a &[f32]) is typically bad practice! Unless it is critical to the correct functionality of that function, it is not a good idea to encode memory-layout into the function prototype.

The Rust Iterator trait solves this elegantly by allowing any implementation of the concept "to iterate", and by exposing only that from a function return type, it allows flexibility in the implementation itself to store data how it wishes.

Caveats

Of course it is possible to design a counter-argument; for example, what if we don't care for 98% of the values? What if we know exactly which 2 items we'd like to access, but they're in the middle somewhere?

for (i, v) in values.iter().enumerate() {
  if i == 24 || i == 92 {
    // use value as 24 and 92 are "known interesting" datapoints?
  }
}
  

Of course, this has "moved the goalposts" -> a .get_value_for_idx() API would be much more suitable!

fn get_value_with_idx(idx: usize) -> Option<f32>;

let v_24 = get_value_with_idx(24).unwrap();
let v_92 = get_value_with_idx(92).unwrap();

Oh look -> the user now has bounds checks neatly integrated, and a high-performance API again. As we can see, there's always other variants and changes possible, but generally when coming from a C/C++ background it is "natural" to reach for &[f32] to return data... but IntoIterator<Item = f32> is much better.

Outro

Hopefully this brain-dump of a low-level C-coder-but-Rust-learner helps change the approach to a problem in your API design - thinking in terms of "compute abstractions" instead of "memory abstractions" is a good skill to have.