Examples of declarative style in Rust

Radek Vít
4 min readFeb 17, 2021

In most low-level programming languages, we are used to describing how what we want is achieved, rather that what we want to achieve in the first place. In some cases, this imperative approach outperforms any other approaches in time and memory complexity.

There exist cases, however, where we are able to write declarative statements that are just as effective as their imperative counterparts, while being much easier to read and easier to change and maintain. In this article, I would like to highlight three declarative use-cases that Rust excels in.

Iterators

In Rust, implementing iterators is not a huge untertaking. All you need to do for a type is to implement a single function fn next(&mut self) -> Option<Item> for the Iterator trait. In return, we automatically gain a whole range of methods on our iterators that allow us to write simple and expressive queries and transformations on our iterators.

If we wanted to get the third last ASCII vowel from a string, the Rust implementation could simply reverse the iterator over strings, filter out everything that isn’t the vowel, and return the third element of the sequence. As a bonus, if we wanted to add more transformations or change the requirements of the function, doing that would be trivial (as we would only add/remove some transformation in the chain). (Credits to agersant and gatosatanico on Reddit for simplifying the example further.)

fn third_last_vowel(input: &str) -> Option<char> {
let is_vowel = |c: &char|
matches!(c, 'a' | 'e' | 'i' | 'o' | 'u' | 'y');
input.chars().rev().filter(is_vowel).nth(2)
}

By comparison, the trivial C++17 implementation would have to be highly specialized for this use case: we would manually keep a counter to make sure we skip the last two vowels, and we would have to use reverse iterators (or worse, indices) to iterate from the back.

std::optional<char> third_last_vowel(std::string_view input)
noexcept {
auto is_vowel = [](auto c) noexcept {
return c == 'a' || c == 'e' || c == 'i' ||
c == 'o' || c == 'u' || c == 'y';
};
unsigned counter {0};
for (auto it = input.crbegin(); it != input.crend(); ++it) {
if (!is_vowel(*it)) continue;
++counter;
if (counter == 3) return *it;
}
return std::nullopt;
}

The C++17 standard library does provide algorithms that could help us with this task, although it would require us to use stateful lambdas. This implementation has much more meaning apparent in the code, but what we’re doing is much less clear than the Rust variant, and there’s much more clutter.

std::optional<char> third_last_vowel(std::string_view input)
noexcept {
auto is_vowel = [](auto c) noexcept {
return c == 'a' || c == 'e' || c == 'i' ||
c == 'o' || c == 'u' || c == 'y';
};
auto it = std::find_if(
input.crbegin(),
input.crend(),
[&, counter {0}](auto c) mutable noexcept {
if (is_vowel(c)) return ++counter == 3;
return false;
}
);
if (it != input.crend()) return *it;
return std::nullopt;
}

Serialization and Deserialization

Whether we communicate with a remote service or we load our application’s settings from a file, serialization and deserialization is a very common operation.

Rust’s procedural macros enable libraries to generate a lot of useful code for us. The serde library provides us devices to generally and efficiently generate serialization and deserialization code for any Rust structure (including optional customization).

In this example, all we need for a struct or enum is #[derive(Serialize, Deserialize)] before its declaration to gain the ability to serialize and deserialize it. We then use libraries for JSON and TOML to serialize and deserialize our structures, all without having to write a single line of code aside from the derive declarations.

// in Cargo.toml [dependencies]:
// serde = { version = "1.0", features = ["derive"] }
// serde_json = "1.0"
// toml = "0.5"
use serde::{Deserialize, Serialize};
#[derive(Debug, Deserialize, Serialize)]
struct Settings {
name: Option<String>,
aggressive: bool,
}
#[derive(Debug, Serialize, Deserialize)]
struct Data {
data: Vec<String>,
settings: Settings,
}
fn main() {
// deserialize from TOML
let data: Data = toml::from_str(
r#"
data = ["a", "b", "c"]
[settings]
aggressive = true
"#
)
.unwrap();
// serialize to JSON
println!("{}", serde_json::to_string(&data).unwrap());
}

There exists nothing comparable to this in C++. We cannot add one derive to our classes to make them serializable. Without reflection, C++ will not be able to do this for some time in the future.

Command line arguments

Another common operation is handling command line arguments. With structopt, we are able to simply declare what our arguments are (including subcommands in our example), and the command line handling, parsing, and help pages are generated for us.

The following example is enough to have a program that has
1. Two subcommands, with different arguments for them
2. Error messages for missing arguments
3. Help pages for the entire program, as well as for each subcommand
4. Custom subcommand help messages, and default values for some of the arguments.

// in Cargo.toml [dependencies]:
// structopt = "0.3"
use structopt::StructOpt;
#[derive(Debug, StructOpt)]
struct RectangleOpts {
#[structopt(short, long)]
height: u8,
#[structopt(short, long)]
width: u8,
}
#[derive(Debug, StructOpt)]
struct CircleOpts {
#[structopt(short, long, default_value = "5")]
radius: u8,
}
#[derive(Debug, StructOpt)]
enum Args {
#[structopt(about = "Draws a circle")]
Circle(CircleOpts),
#[structopt(about = "Draws a rectangle")]
Rectangle(RectangleOpts),
}
fn main() {
let args = Args::from_args();
println!("{:?}", args);
}

Like with the previous example, there exists nothing comparable to this in C++. Good frameworks still let us declaratively list our options, but none of them are able to extract data as conveniently and accurately as the Rust version can. With Rust, we can treat subcommands and their arguments as just data, and have the library handle the argument handling for us.

--

--