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. traits 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 impls 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.