Rust: How to use generics
Designing a good architecture for your application is a challenging task. Moreover, as the system grows and requirements change, the original blueprints might no longer be applicable to the current needs. Fortunately for us developers, there is a concept of generic programming that can significantly improve maintainability of the code potentially requiring fewer adjustments in future.
Prerequisites
I’m using Rust version 1.67.0. This tutorial assumes prior knowledge of Rust and concentrates more on practical usage of the language. I’ll continue working on the codebase from my previous tutorial, and I do recommend you read that one first to better understand the background.
As always, project sources are available at GitHub, find the link at the end of the page.
There are 3 parts in this tutorial: Problem statement, Generics for envy
, Generics for API calls
Part 1. Problem statement
To illustrate the true power of generics we will solve two problems in the scope of this tutorial.
First one is about the way env variables are fetched. Consider the following piece of code:
Here, in order to get the port
value we first need to get the variable from the .env
file, check that it is present, parse it or panic
in case it is not parsable. With more variables added instead of copy-pasting the code from one place to another we will introduce a dedicated module env_utils
that will encapsulate this logic. That would be a good and easy example to start working with generics, especially if you are coming from a language that doesn’t have this concept.
Second one is about requesting and processing the ImmutableX API to retrieve mint
and asset
data. As you remember, in the previous tutorial we introduced mint_reader
:
Now as the application evolves we also want to fetch all the assets data. Luckily, both APIs are designed in a similar way so that we can minimize duplication with the help of generics. This example will use structs
, traits
and the async-trait crate.
Part 2. Generics for envy
Just to recap, the environment variables are fetched from .env
file using the envy crate. As outlined in the previous part, this operation is quite verbose, thus copying it all over the code-base will introduce an unnecessary maintenance burden. Instead, let’s create a utility module that will hide the logic:
Now we can use it directly in db_handler
like so:
But this solution has a very obvious drawback. With more env variables of different types added we would need to go back to env_utils
and create a method for each of those. Here generics come to rescue! Instead of having a function that returns a concrete type let’s abstract it away with T
:
With the help of the new method we can now parse env variables of the supported types: port(env_utils::as_parsed::<u16>(“DB_PORT”))
or some_bool(env_utils::as_parsed::<bool>(“MY_BOOL”))
Note! I’ve left the
as_string
method in the utils since most of the env variables in this application are strings. It is not actually required and can be replaced withenv_utils::as_parsed::<string>(“DB_DATABASE”)
As you can see in the code snippet above, we used generics by not specifying a concrete type (such as u16
or bool
) but by abstracting it away with T
. The letter T
doesn’t have any significant meaning as such, but it is a convention across programming languages to use it as a type parameter. On top of that there is also a where clause that is used to specify bounds on type parameter T
. In this particular case the parse
function (that is also using generics under the hood) requires the parameter to be bound by (aka implement) FromStr
and Debug
traits.
Part 3. Generics for API calls
Moving on to a more complex example. Now we will rewrite the previously created mints_reader
module using generics, in order to add support for the assets API.
Just to recap, we have the following mint
model already:
Following the ImmutableX specs, we will add the following asset
model:
You might have already spotted a few similarities between mint
and asset
, namely they both have a cursor
field and a vec
of results
.
With the model now in place let’s take a step back and identify all the changes required for the mints_reader
module created previously:
I’ll follow the line numbers from the gist:
- (0) Since this module will now be responsible for more than just
mints
it should be renamed to something more generic, sayreader
- (4) The
read
function should either be generic or its code extracted into a separate generic function. We will go with the latter option - (19) The
url
var should not use a staticMINTS_URL
field anymore - (20) The
fetch_api_response
function (which was using generics already) should not referenceMint
as it’s type - (24) (28) Those two calls shouldn’t be referencing
mint
- (25) We should find a way to persist both
mint
andasset
Sounds like a lot of changes! At this point you might be tempted to simply copy-paste the existing solution and change Mint
to Asset
where needed. But believe me, in the long run such complex refactoring will pay off.
In my opinion it is beneficial to illustrate an iterative approach to refactoring of this kind. Thus, it will be split into 3 major parts.
We will begin with the first 4 points, those are low hanging fruits. After all the outlined changes are in place the interim version of the reader
module will look like this:
Note! As this is an iterative process it is perfectly acceptable to have commented-out code in between the iterations. You just need to make sure that this code 1) compiles and 2) is not persisted in git history
At this moment we have already abstracted away all types in read_with_cursor_as
and subsequent functions. Now, in order to implement required changes for (24) and (28) we need to adjust the models accordingly. As it was noticed earlier, both mint
and asset
have a cursor
field and a vec
of results
. Which suggests we should be able to abstract those fields away. In Rust traits are used to define shared behavior. Let’s define it for our models:
Now we can impl
it for mint
and asset
:
Both models are now implementing PaginatedApi
, so we can go ahead and apply further changes to reader
:
Note that now T
is bound by both DeserializeOwned
and PaginatedApi
traits
. That makes it possible to call has_results
and get_cursor
methods on result
returned from fetch_api_response
.
Last remaining step is to persist the fetched data into the database. Currently, we only have one module responsible for saving mint
:
In order to make it generic, we will combine traits with structs here.
Let’s define the trait
first. From looking at the existing implementation we can see that to save mint
we need a vec
of results
and Postgres pool
. Thus we can define a trait
like this:
As you can see here I’m also changing the vec
of results
to T
to be more future-proof on the persistence layer.
Note! As of time of writing, Rust only supports async traits in nightly builds. Eventually, the support should also be added in the production build, but for now async_trait crate is used. You can read more about it in the official blog post.
Next, we will create two separate modules responsible for saving the models. Each of those modules will define a struct
that will implement the Persistable
trait.
Previously implemented save_mint
method is now moved to mints_handler
:
And a new module to save assets
will be created:
With those changes in place we can now finish reader
implementation:
Note! Using
dyn traits
has its own trade-offs, please do read the official documentation about it!
You can see that now the methods accept a persistable
trait
of type T
, thus abstracting away a concrete implementation. This approach makes it straightforward to add support for more APIs, making the code easily extendable.
Afterwards
Generic programming is a highly valuable skill for developers to have in their toolset. It empowers them to write more resilient and scalable code while reducing redundancy. This tutorial showcases the power of generic programming in Rust, but even if you don’t yet use Rust, I hope it inspires you to reconsider your programming practices and embrace generic programming in your primary language.
All of the code can be found at this GitHub repository.
Support
If you like the content you read and want to support the author — thank you very much!
Here is my Ethereum wallet for tips:
0xB34C2BcE674104a7ca1ECEbF76d21fE1099132F0