Suppose we want to create a validation function for a struct, to ensure that a number is between two values (for example, for validating a HTML form's submission). However, we'd like to make this function generic over a variety of integer values. With Go's recent introduction of generics, this is very straight forward to implement. We simply create the interface that specifies which types will be permitted, and then make a function that is generic over those types:
package main
import "fmt"
type magnitudal interface {
int16 | int32 | int64
}
func isBetween[T magnitudal](val T, min T, max T) bool {
return val >= min && val <= max
}
func main() {
var input int16 = 5
fmt.Printf("input %d is between 1 and 10: %t\n", input, isBetween(input, 1, 10))
}
Fortunately (?), Go allows us to use >=
and <=
in this function, because it's available for every type specified in the magnitudal
interface. The same is not true if a field happens to be shared by all types in the interface -- we can't access that field just because all types have a field of the same type and name.
For Rust, let's do it the hard way first, so we can understand better how the easy way works. Let's create a trait called Magnitudal
that plays a similar role to the Go magnitudal
interface. We listed the types in our 'magnitudal' interface, and because we can use =>
and <=
on all those types, isBetween can be called for a variety of integer types. For Rust, we create a Magnitudal
trait and implement it for every type we want to allow into our function. Here is an example:
pub trait Magnitudal {
fn between(self, a: Self, b: Self) -> bool;
}
impl Magnitudal for i16 {
fn between(self, a: Self, b: Self) -> bool {
self >= a && self <= b
}
}
fn main() {
let input: i16 = 5;
println!(
"input {:?} is between 1 and 10: {:?}\n",
input,
input.between(1, 10)
);
}
This gives us the output:
% cargo run
[...]
input 5 is between 1 and 10: true
Suppose now we wanted to allow this function to be used for more types than just i16
. Much like the 'magnitudal' interface in the Go code had to list all the types that are permitted, we need to implement the 'Magnitudal' trait for every type that we want to permit. We could do this by hand. For example, adding i32 and i64:
impl Magnitudal for i32 {
fn between(self, a: Self, b: Self) -> bool {
self >= a && self <= b
}
}
impl Magnitudal for i64 {
fn between(self, a: Self, b: Self) -> bool {
self >= a && self <= b
}
}
However, this could get quite cumbersome and repetitious as we add more types. However, we can mitigate some of this repetitiveness through macros. I adore the idea of macros in Rust, but I've only just started experimenting with them. Even with the introduction of Go's generics, I still find myself reaching for generated code for some things that can't yet be done easily with code alone. For example, as mentioned above, in Go 1.18 you can't call a field that happens to be shared by all types permitted by a type interface. This may arrive in later versions, but isn't possible to do yet. Having something like macros processed by the compiler, to auto generate code, is a great boon.
Let's create a macro that will implement Magnitudal
for any type we list, so that adding a new type is just a matter of adding it to the macro call (or calling the macro again):
macro_rules! impl_magnitudal {
(for $($t:ty),+) => {
$(impl Magnitudal for $t {
fn between(self, a: Self, b: Self) -> bool {
self >= a && self <= b
}
})*
}
}
impl_magnitudal!(for i16, i32, i64);
Without going into details on how this macro works, we can call impl_magnitudal!(for a, b, ...)
and list all the types, and it will generate the code automatically for each of those types as defined by the macro. Once the macro is created, adding the implementation for a new type is as easy as passing those types as arguments to the macro. The code produced is the same as the hand-written implementation for each type. Still not as simple as the Go setup, since we still need to implement a function called between
, while the Go code was able to use the >=
and <=
operators directly to create a generic function rather than a function implemented on each type.
We can improve on this. Rust allows us to implement traits on types so that we can make use of operators like + - < >
with that type. The operations we need, <=
and >=
, are implemented via the PartialOrd trait, and have already been implemented for all the integer types we are interested in. For example, see the docs for the i32 implementation of PartialOrd. That means that we can create our between
function not as something implemented by a type, but rather as a standalone function, restricting it to all and only types that allow us to use >=
and <=
!
So let's forget using our own trait and its accompanying macro, and instead create a generic function that allows any PartialOrd types as an input. Here is the entire program:
fn between<N: PartialOrd>(val: N, min: N, max: N) -> bool {
val >= min && val <= max
}
fn main() {
let input: i16 = 5;
println!(
"input {:?} is between 1 and 10: {:?}\n",
input,
between(input, 1, 10)
);
}
Rather than implmenting our own type, we declare a function generic over any type that implements PartialOrd, and accepts that same type as an input, min, and max value. This is actually more concise than the Go version, but this is because the hard work of implementing all the myriad traits for the myriad types has been done in the Rust standard library for us, and the same could be done for Go. With new types of our own invention, we'd need to go through the process of implementing the trait for all the types we wanted.
If the numeric traits implemented in the standard library are not enough, there is a num package that goes further. For example, if we wanted to only allow integer types for between
, we can use the num package's Integer
trait to do exactly that:
use num::Integer;
fn between<N: Integer>(val: N, min: N, max: N) -> bool {
val >= min && val <= max
}
fn main() {
let input: i16 = 5;
println!(
"input {:?} is between 1 and 10: {:?}\n",
input,
between(input, 1, 10)
);
}
And make sure to include the new dependency in your Cargo.toml file:
[dependencies]
num = "0.4"
The num package makes use of macros similar to how we did earlier, to reduce the repetitive implementation of the trait for all the desired types. The Integer
trait itself includes PartialOrd
, so when restricting types to Integer
, we can still call on the <=
and >=
operators.
Go has 'duck typing', where something fulfills an interface just in case it implements all methods described in the interface. Rust, on the other hand, requires explicitly declaring an implementation of a trait. It's not enough that the type has the method, the method has to be specified as implementing that method for the trait. Ultimately, I lean towards preferring Rust's approach here, because it avoids situations where a type just happens to fulfill an interface by chance with methods that are actually intended to play a completely unrelated role. This gives us more confidence that a type implementing a trait does so because it's intended to be used for that trait.
In Go, we can't implement operators for our own types. The Rust solution here can be expanded to accept any new numeric types we might create, but with Go we can't use the general implementation above on custom types. We would need to resort to using reflection, or implementing methods for each type and then calling those methods instead of using >=
and <=
directly. I've only started playing with Go generics recently, but as mentioned earlier I'm finding that they are limited in some ways that I would like to use them. Hopefully they improve in their capabilities over time to more gradually eliminate my need for generating code.
Top comments (0)