Template specialization
Template specialization in C++ makes it possible for a template entity to have
different implementations for different parameters. Most STL implementations
make use of this to, for example, provide a space-efficient representation of
std::vector<bool>
.
Because of the possibility of template specialization, when a C++ function
operates on values of a template class like std::vector
, the function is
essentially defined in terms of the interface provided by the template class,
rather than for a specific implementation.
To accomplish the same thing in Rust requires defining the function in terms of a trait for the interface against which it operates. This enables clients to select their choice of representation for data by using any concrete type that implements the interface.
This is more practical to do in Rust than in C++, because generics not being a general metaprogramming facility means that generic entities can be type checked locally, making them easier to define. It is more common to do in Rust than in C++ because Rust does not have implementation inheritance, so there is a sharper line between interface and implementation than there is in C++.
The following example shows how a Rust function can be implemented so that
different concrete representations can be selected by a client. For a compact
bit vector representation, the example uses the
BitVec
type
from the bitvec crate. BitVec
is
intended intended to provide an API similar to Vec<bool>
or
std::vector<bool>
.
#include <string>
#include <vector>
template <typename T>
void push_if_even(int n,
std::vector<T> &collection,
T item) {
if (n % 2 == 0) {
collection.push_back(item);
}
}
int main() {
// Operate on the default std::vector
// implementation
std::vector<std::string> v{"a", "b"};
push_if_even(2, v, std::string("c"));
// Operate on the (likely space-optimized)
// std::vector implementation
std::vector<bool> bv{false, true};
push_if_even(2, bv, false);
}
// The Extend trait is for types that support
// appending values to the collection.
fn push_if_even<T, I: Extend<T>>(
n: u32,
collection: &mut I,
item: T,
) {
if n % 2 == 0 {
collection.extend([item]);
}
}
use bitvec::prelude::*;
fn main() {
// Operate on Vec
let mut v =
vec!["a".to_string(), "b".to_string()];
push_if_even(2, &mut v, "c".to_string());
// Operate on BitVec
let mut bv = bitvec![0, 1];
push_if_even(2, &mut bv, 0);
}
Trade-offs between generics and templates
Because generic functions can only interact with generic values in ways defined by the trait bounds, it is easier to test generic implementations. In particular, code testing a generic implementation only has to consider the possible behaviors of the given trait.
For a comparison, consider the following programs.
template <totally_ordered T>
T max(const T &x, const T &y) {
return (x > y) ? x : y;
}
template <>
int max(const int &x, const int &y) {
return (x > y) ? x + 1 : y + 1;
}
#![allow(unused)] fn main() { fn max<'a, T: Ord>(x: &'a T, y: &'a T) -> &'a T { if x > y { x } else { y } } }
In the Rust program, parametricity means that (assuming safe Rust) from the
type alone one can tell that if the function returns, it must return exactly one
of x
or y
. This is because the trait bound Ord
doesn't give any way to
construct new values of type T
, and the use of references doesn't give any way
for the function to store one of x
or y
from an earlier call to return in a
later call.
In the C++ program, a call to max
with int
as the template parameter will
give a distinctly different result than with any other parameter because of the
template specialization enabling the behavior of the function to vary based on
the type.
The trade-off is that in Rust specialized implementations are harder to use because they must have different names, but that they are easier to write because it is easier to write generic code while being confident about its correctness.
Niche optimization
There are several cases where the Rust compiler will perform optimizations to achieve more efficient representations. Those situations are all ones where the efficiency gains do not otherwise change the observable behavior of the code.
The most common case is with the Option
type. When
Option
is used with a type where the compiler can tell that there are unused
values, one f those unused values will be used to represent the None
case, so
that Option<T>
will not require an extra word of memory to indicate the
discriminant of the enum.
This optimization is applied to reference types (&
and &mut
), since
references cannot be null. It is also applied to NonNull<T>
, which represents
a non-null pointer to a value of type T
, and to NonZeroU8
and other non-zero
integral types. The optimization for the reference case is what makes
Option<&T>
and Option<&mut T>
safer equivalents to using non-owning
observation pointers in C++.