Function Overloading in Rust
I just made a pull request to reqwest. I thought this particular one was interesting enough to be worth blogging about, so I am.
We know that many C++ family languages have a feature known as function overloading, where two functions or methods can exist with the same name but different argument types. It looks something like this:
void use_connector(ConnectorA conn) {
// IMPL
}
void use_connector(ConnectorB conn) {
// IMPL
}
The compiler then chooses which method to call, at compile-time, based
on the static type of the argument. In C++, this is part of compile-time
polymorphism, an easy “if
statement” in the template meta-language. In
Java and many other languages, it’s merely a convenience, for when an
ad-hoc group of types are possible for what an outsider sees as the
same operation, but which from the perspective of the library requires
different implementations.
Rust does not support this, at least not in this form. This is a mildly
controversial decision; I’ve seen many people complain about it,
because it is a commonly-used feature in the languages they’ve come
from. Ultimately, I think Rust made the right call. There are too
many advantages of having a one-to-one correspondence between method or
function names and implementations, and ultimately I think the feature is
more confusing than helpful. trait
s cover a lot of the same ability,
but in a more structured fashion, acting like C++’s compile-time
“if
-statements.” But of course, there is always a learning curve giving
up a feature you’re used to using.
But just because Rust doesn’t officially support function
loading as a feature, surprisingly doesn’t mean that it’s
completely impossible. Recently, I was looking into the
depths of reqwest
,
trying to troubleshoot an issue, and I came across this
code:
#[cfg(any(feature = "native-tls", feature = "__rustls",))]
#[cfg_attr(docsrs, doc(cfg(any(feature = "native-tls", feature = "rustls-tls"))))]
pub fn use_preconfigured_tls(mut self, tls: impl Any) -> ClientBuilder {
let mut tls = Some(tls);
#[cfg(feature = "native-tls")]
{
if let Some(conn) =
(&mut tls as &mut dyn Any).downcast_mut::<Option<native_tls_crate::TlsConnector>>()
{
let tls = conn.take().expect("is definitely Some");
let tls = crate::tls::TlsBackend::BuiltNativeTls(tls);
self.config.tls = tls;
return self;
}
}
#[cfg(feature = "__rustls")]
{
if let Some(conn) =
(&mut tls as &mut dyn Any).downcast_mut::<Option<rustls::ClientConfig>>()
{
let tls = conn.take().expect("is definitely Some");
let tls = crate::tls::TlsBackend::BuiltRustls(tls);
self.config.tls = tls;
return self;
}
}
// Otherwise, we don't recognize the TLS backend!
self.config.tls = crate::tls::TlsBackend::UnknownPreconfigured;
self
}
I was shocked to see this! I felt like I was reading Java.
My first thought was that this was the Java instanceof
(anti-)pattern,
but after a little more thought, I realized that this in practice
would work out to function overloading.
Since this uses impl Any
instead of &mut dyn Any
, this function
will be monomorphized at compile-time, and I would expect that
the relevant branching would be collapsed, resulting in these
monomorphizations, written in an imaginary version of Rust where
function overloading is supported:
#[cfg(feature = "native-tls")]
pub fn use_preconfigured_tls(mut self, tls: native_tls_crate::TlsConnector) -> ClientBuilder {
let tls = crate::tls::TlsBackend::BuiltNativeTls(tls);
self.config.tls = tls;
self
}
#[cfg(feature = "__rustls")]
pub fn use_preconfigured_tls(mut self, tls: rustls::ClientConfig) -> ClientBuilder {
let tls = crate::tls::TlsBackend::BuiltRustls(tls);
self.config.tls = tls;
self
}
There is a wrinkle though. Unlike the Java or pseudo-Rust equivalent, the Rust
code in reqwest
will still allow functions to compile if they specify
another type that is not one of the two supported. So you can call this
function with anything, even an i32
, and the compiler won’t signal
an error or even a warning:
client_builder.use_preconfigured_tls(42); // COMPILES!
In this implementation, it eventually causes a run-time error instead (a
separate function produces it in the case of UnknownPreconfigured
). But
this odd type-safety work-around still can’t be removed without breaking
API-compatibility. Code could theoretically be relying on this function
producing a run-time error in certain situations, or it could rely on
that other function not being called. Luckily, reqwest
is not 1.0,
and I have reason to hope they won’t consider this problematic.
There are other ways to accomplish the same goal. Instead of an ad-hoc
list of supported types, this code could’ve used a trait
. Such code
would look something like this:
pub trait TlsConfig {
fn to_tls_backend(self) -> crate::tls::TlsBackend;
}
#[cfg(feature = "native-tls")]
impl TlsConfig for native_tls_crate::TlsConnector {
fn to_tls_backend(self) -> crate::tls::TlsBackend {
crate::tls::TlsBackend::BuiltNativeTls(self)
}
}
#[cfg(feature = "__rustls")]
impl TlsConfig for rustls::ClientConfig {
fn to_tls_backend(self) -> crate::tls::TlsBackend {
crate::tls::TlsBackend::BuiltRustls(self)
}
}
pub fn use_preconfigured_tls(mut self, tls: impl Tls) -> ClientBuilder {
self.config.tls = tls.to_tls_backend();
self
}
This would allow the library to be used in the exact same way for valid
uses, but would still allow the compiler to catch invalid types. To be
sure, the trait
and its impl
s would have to be separated in the
code from the use_preconfigured_tls
method, as you can’t put
a trait
inside an impl
block. But I think such an inconvenience
is worth the better type-safety.
My take-away here is to be wary of emulating features from other
programming languages, and also to be wary of std::any
.
Addendum/Errata#
I was wrong about the existing code not providing a run-time error. It
sets an enum
to UnknownPreconfigured
, which then triggers a run-time
error elsewhere in a separate function. The article has been updated
accordingly.
The trait
example code was also edited to reflect a version that actually
compiles, but not the final version in the MR.
I also edited the intro to clarify the relationship between function overloading and traits.
The MR was ultimately rejected for reasons I deeply disagree with.
Subscribe
Find out via e-mail when I make new posts! You can also use RSS (RSS for technical posts only) to subscribe!
Comments
If you want to send me something privately and anonymously, you can use my admonymous to admonish (or praise) me anonymously.
comments powered by Disqus