Updated on 26 July 2019

There is a strong need to work with typical business logic operations in a platform-agnostic manner for increased code re-use, reduced dependencies, and higher performance. Consider Rust. The language is a prime candidate for replacing C++, at large, as a systems language but can it also serve the needs of general consumer applications? This article is written for somebody interested in how Rust can be used by someone with experience in other modernized application programming languages like Swift and Kotlin.

So is Rust good for handling business logic? Is it good as an app language?

I’ve been using the DynamoDB API as a test bed with at least five or six languages now to accumulate data points for my evaluation. The reason behind this exploration is to converge on cohesive techniques for implementing multiplatform architecture based on shared libraries that can support a mostly functional, declarative style of programming.

To be fair, I haven’t given equal consideration to Haskell or OCaml as I don’t have the requirement for a language to be as “purely” functional as they allow. My choices are not based solely on the language but rather the accompanying ecosystem, including community support and package availability. There is also an element of playing with new toys that is hopefully tempered by rational considerations.

I’ll cover the following areas about Rust programming.

  • The Rust type system
  • Futures (synced and async polled)
  • Iterators
  • Mapping
  • Closures
  • Vec (array-like)
  • HashMap
  • Generics
  • Unit testing

Rust, with its implicit return values and unit type, is reminiscent of F# to me (without requiring a .NET runtime) while having the ability to control bare metal. This gives it awesome potential for performant functional-style programming. Given the choice between working with a functional, strongly typed, ML-derived, systems language versus an imperative, weaker typed, Algol-based one, the former has significant advantages when it comes to developing programs that can evolve over time. It is due to supporting more of a bottom-up approach through functional composition rather than one that is top-down via duck-typed class hierarchies.


Rust logo

And now Rust

After comparing many different languages and their ecosystems, I’m ready to give a little more time to Rust. A language should serve as a tool for what one wishes to express. Philosophically, Rust matches my preferences along with having the potential for high performance, runtime safety, and interoperability (via C bindings) with environments for other languages.

While there is no official AWS SDK for Rust, the Rusoto project seems stable and comprehensive enough for real use. The examples herein make use of it where I have limited them to a single operation, deleting tables, because I found numerous ways to approach this single problem that led me on a path of learning. I’ll show how my individual functions evolved to fit my expanding requirements.

The AWS DynamoDB API uses inputs and outputs. They are used similarly in the AWS SDKs for other platforms. For deleting a table, a delete input is made that is passed to the delete command. Upon execution, a delete output is available. This is what it looks like in Rust:

 1 fn delete_table(name: &str) 
 2     -> (String, String)
 3 {
 4     let table_name = name.to_string();
 5     let input = DeleteTableInput { table_name };
 6     let result = client().delete_table(input).sync();
 7     match result {
 8         Ok(_output) => (name.to_string(), "deleted".to_string()),
 9         Err(err) => (name.to_string(), err.to_string())
10     }
11 }

The sync indicates a thread-blocking operation. The underscore in front of output says I’m ignoring that value, for now. The delete table input creation is in shorthand form because there is a matching local table_name argument in name and type. The result gives a Result<DeleteTableOutput, RusotoError<DeleteTableError>>.

In just a single function, one can get a feel of how explicit the type handling is under the Rust type system. There is some shortening of the need to specify everything through type inference but the compiler still treats each type strictly.

To get started, I’m returning a tuple of strings consisting of the table name and a descriptive message. Due to ownership rules where a value can be moved among owners, the string reference (&str) is converted to an opaque value when needed. String literals default to &str instead of std:string::String. This may seem strange at first, but it ends up being a sensible choice given how ownership and borrowing work.

Multiple tables

Now that one table can be deleted, the next logical step is be able to delete any number of tables. Perhaps, one of the most familiar ways is to use a for-in loop like so:

1 fn delete_tables_by_for_in()
2 {
3     for table in all_tables() {
4         delete_table(table);
5     }
6 }

In case loops are not desired, mapping also works and the results can be collected into a growable, array-like structure called a Vec.

 1 fn delete_tables_by_map()
 2 {
 3     let deleted: Vec<(String, String)> = 
 4         all_tables()
 5             .into_iter()
 6             .map(|x| {
 7                 delete_table(x)
 8             }).collect();
 9     println!("{:?}", deleted);
10 }

Nothing too difficult there, right?

Transitioning to asynchronous polling

Blocking operations are good for testing but may not be sufficiently flexible for performance and user experience in production.

The underlying type for these actions is RusotoFuture. It is an implementation of a futures::future::Future (distinct from a std::future::Future), a trait that contains the two associated types, Item and Error.

The delete table Future can be written as a function where the implementation is its return type:

1 fn delete_table_future(table_name: &str) 
2     -> impl Future<Item=DeleteTableOutput, 
3                    Error=RusotoError<DeleteTableError>>
4 {
5     let make_input = DeleteTableInput { table_name: table_name.to_string() };
6     client().delete_table(make_input)
7 }

Futures do not perform actions until polled. Therefore, this can be considered the declarative form of the previous imperative version that called sync().

To use this, my first thought was to map the results into a Vec. I show a nonworking attempt below that is not compilable but shows what were my intentions.

 1 // 👎 Non-working attempt to run futures.
 2 fn nonworking_delete_tables_with_future_by_map() 
 3     -> Vec<DeleteTableOutput>
 4 {
 5     let result: Vec<_> = all_tables().into_iter().map(|x| {
 6         delete_table_future(x).map(|output| {
 7             output
 8         })
 9     }).collect();
10     result
11 }

The idea was to collect the DeleteTableOutputs into a Vec. However, map was not successful for getting a Future to run. Instead, there is a runner in tokio_core::reactor::Core. It takes the future as an argument (passed by value, i.e. not a pointer).

1 fn run_delete_table_future(table_name: &str) 
2     -> (String, Option<RusotoError<DeleteTableError>>)
3 {
4     match Core::new().unwrap().run(delete_table_future(table_name)) {
5         Ok(_item) => (table_name.to_string(), None),
6         Err(e) => (table_name.to_string(), Some(e))
7     }
8 }

I start to return the actual error (instead of a string) here as the second member of the return tuple. These tuples can be collected nicely into a HashMap where a delete table call function can now be written as:

1 fn tables_deleted() 
2     -> HashMap<String, 
3                Option<RusotoError<DeleteTableError>>>
4 {
5     all_tables().into_iter().map(|x| {
6         run_delete_table_future(x)
7     }).collect()
8 }

Is that not more beautifully compact considering where we started? I think so, but there’s more that can be done.

Generics - For the ability to write less code

In a real app, there is a need to support large numbers of DB operations. Do we have to make a separate runner for each one? Or is there a way to handle them generically? If the Future can be passed as a function parameter, then a single runner can be used for all of them, in general. The next function illustrates the result of reasoning out a way to make the runner more generic.

Due to the contract in the Future trait, there’s a little, helpful path syntax that can be used to get to the error in anything that implements it. For example, if there’s a generic type U, U::Error gets the error. To start with, we’ll collect the errors from a generic future runner by letting our previous return type of DeleteOutputError be generically determined by placing the generic type in the return value position of the Future calling function reference that is passed in as an argument to the runner.

1 fn run_future_generically<U>(some_future: &Fn() -> U) 
2     -> Option<U::Error> where U: futures::Future
3 {
4     match Core::new().unwrap().run(some_future()) {
5         Ok(_item) => None,
6         Err(e) => Some(e)
7     }
8 }

I’m having the runner return an Option through the pattern matching on the result of the Future. This is for convenience later to separate the operations that have errors. Setting the generic condition or constraint (trait bound), that U is a Future, ensures that only Errors from a Future will be returned. This seems like an enormous improvement to writing a separate runner for each DB operation, doesn’t it?

It allows passing delete_table_future, or some other operation, directly into the runner where the runner, itself, only has to deal with an Item and an Error. That effectively provides all the benefits of separating responsibilities without requiring any hierarchical OOP structure (instead being interface-mediated behaviors) and it works for all the DB operations that can be represented as a Future.

This is a major turning point in that the details of running each operation are no longer a separate implementation, or function, as they might be without generics. I can simply declare all behavior in the function that is passed in. If I just want to know delete table errors, then I can set that as the return type, as below.

1 fn tables_deleted_generically() 
2     -> Vec<Option<RusotoError<DeleteTableError>>>
3 {
4     let result: Vec<_> = all_tables().into_iter().map(|x| {
5         run_future_generically(&|| { delete_table_future(x) })
6     }).collect();
7     result
8 }

For convenience, I’ve collected the errors into a Vec. What if we want to also have the DeleteTableOutput for knowing things like the table name according to my original returning of strings? From all the previous definitions, hopefully, it can be seen quite clearly how to do this.

1 fn tables_deleted_generically() 
2     -> Vec<(Option<DeleteTableOutput>,
3             Option<RusotoError<DeleteTableError>>)>
4 {
5     let result: Vec<_> = all_tables().into_iter().map(|x| {
6         run_future_generically(&|| { delete_table_future(x) })
7     }).collect();
8     result
9 }

The return type is modified to include the Future output contained in its Item property. This change requires a little corresponding modification to the runner function.

1 fn run_future_generically<U>(some_future: &Fn() -> U) 
2     -> (Option<U::Item>,
3         Option<U::Error>) where U: futures::Future
4 {
5     match Core::new().unwrap().run(some_future()) {
6         Ok(item) => (Some(item), None),
7         Err(e) => (None, Some(e))
8     }
9 }

In summary, along with being able to automatically handle the return type (via generics), there is no dependency on the size or shape of the Future that is passed in. This is possible because there are zero input arguments (arity 0) on some_future in the runner. 🎉 The implementation details are completely encapsulated in the closure reference to delete_table_future 🎉.

We now have the full result of any Future, wrapped in Options for convenience. Nearly any kind of operation can be performed on the DB, generically! This solution has been derived entirely through the algebra of types. Finally, evaluation of the results (such as for testing) can be accomplished by destructuring the Option pairs:

 1 #[test]
 2 fn test_generic_future_call()
 3 {
 4     let expect = 2;
 5     let mut cnt = 0;
 6     for pair in tables_deleted_generically() {
 7         match pair {
 8             (Some(output), None) =>
 9                 { println!("❌ {}", output.table_description.unwrap().table_name.unwrap()) },
10             (None, Some(_e)) => { println!("⚠️"); cnt += 1; }
11             _ => ()
12         }
13     }
14     assert_eq!(cnt, expect)
15 }

I test this using:

$ cargo test --color=always --package ddb_rust --test db_tests tests::test_generic_future_call -- --nocapture --exact

with nocapture specified to allow output to stdout.

For reference, the following dependencies were used for the previous examples with Rust 1.36.0 (2018 edition).

[dependencies]
rusoto_core = "0.40.0"
rusoto_dynamodb = "0.40.0"
futures = "^0.1"
tokio-core = "^0.1"

Conclusions

Deriving the functions that fulfill the requirements of this project consisted entirely of forming type relationships that satisfy the compiler. What we get is relatively deterministic runtime behavior that can be tested and verified with the opportunity to handle any errors. Is it, therefore, more efficient to write programs in Rust?

Compared to a similar implementation in Go or C++, I’d argue that Rust provides an alternative that reduces overhead both upfront during implementation and in terms of maintenance over time. Regarding style, I was able to express nearly exactly what I wanted. This is unlike Go, where more effort is required by me to fit that language with what I want to express. I believe equivalent functions could be rendered in C++ but, perhaps, without nearly as much help from the compiler.

There is a cost in arriving at the right level of abstraction when applying a bottom-up approach to any given problem. But my experience has indicated this can be successful way to develop a solution that can be readily extended as new requirements are formed, especially with respect to composing functions, or functional units.

Rust aids this process, because we get inherently consistent runtime safety and behaviors due to the pervasive type system and explicit memory model. This benefit comes at little additional cost. In other words, once you can wrap your head around this programming style, Rust can offer major engineering advantages compared to the typical drawbacks of other languages and ecosystems.

It’s interesting to note that most of the dependencies I’m using appear to be under active development. I’m happy to report that package management has been a seamless experience. This is likely due to the excellence of the Cargo crate system even though Cargo.lock seems breathtakingly long (due to Rusoto’s dependencies) for my small amount of code. However, I’m confident build optimizations will be available when I’m ready for them.

In summary, Rust has:

  • Great function re-usability and composability
  • Outstanding capacity to express abstract types and relationships that are enforced by the compiler (unlike Go)
  • Runtime safety assurances reinforced by the type system
  • Bare-metal control features
  • Polished dependency management (unlike C++)
  • Built-in testing (unlike C++)
  • Precise error messages during compilation
  • Support for both functional and imperative programming styles
  • A foreign function interface (FFI)

Overall, writing Rust does not feel extremely different from any other modern programming language. A sufficient background in protocol oriented programming in Swift can be good preparation. With so much going for it, I predict that it can have an extraordinary future based on having a design that addresses some of the biggest problems in software engineering.

Unfortunately, there is much software in the world not built to the same standard as what is produced by the Rust compiler. From inherently unsafe memory models that can lead to security breaches to more devastating errors that can result in loss of life, how software is built matters.

The primary effort in Rust, for me, is the algebra of static types. But I don’t see this as a barrier to adoption. Rather, it is about being willing to invest in the kind of process that I’ve shown. The stunning part is that this skill is transferable to other programming environments. Therefore, the potential dividends seem higher than the cost of learning. That kind of value seems rare and wonderful in a field that regularly evaluates the trade-offs in its available options.



blog comments powered by Disqus