Into
Conversions In-Depth
Preface
This is the continuation of Into Conversions from the Basics
section. This page describes important caveats of using impl Into
that you should know before enabling them.
Make sure you are familiar with the standard From
and Into
traits before you proceed. Reading their docs is pretty much enough. For example, you should know that every type that implements From<T>
automatically implements Into<T>
. Also, you should know that you can pass a value of type T
at no cost directly to a function that accepts impl Into<T>
thanks to this blanket impl in std
.
WARNING
This is generally a controversial topic 🐱. Some people like to be more explicit, but others prefer the shorter notation. This also depends on the kind of code you are writing.
If you prefer being explicit in code, feel free not to use Into
conversions at all. They are fully opt-in. This article isn't prescriptive. The syntax savings are arguably small, so use your best judgement, and refer to this page if you can't decide.
We'll cover the following:
Use Into
conversions
The main advantage of impl Into
in setters is that it reduces the boilerplate for the caller. The code becomes shorter and cleaner, although not without the drawbacks.
Into
conversions usually make sense only if all of the following are true (AND):
The Rules of Into
- The code where the builder is supposed to be used is not performance-sensitive.
- The builder is going to be used with literal values a lot or require wrapping the values.
Shorter syntax for literals
Here is an example that shows the non-exhaustive list of standard types where it's usually fine to enable Into
conversions.
TIP
Switch between the UI tabs in the code snippets below to see how the code looks like with Into Conversions
enabled and with the Default
syntax.
use bon::Builder;
#[derive(Builder)]
struct Example {
#[builder(into)]
string: String,
#[builder(into)]
path_buf: std::path::PathBuf,
#[builder(into)]
ip_addr: std::net::IpAddr,
}
Example::builder()
// We can pass `&str` literal
.string("string literal")
// We can pass `&str` literal or a String
.path_buf("string/literal")
// We can pass an array of IP components or `Ipv4Addr` or `Ipv6Addr`
.ip_addr([127, 0, 0, 1])
.build();
use bon::Builder;
#[derive(Builder)]
struct Example {
// No attributes
string: String,
// No attributes
path_buf: std::path::PathBuf,
// No attributes
ip_addr: std::net::IpAddr,
}
Example::builder()
// We have to convert `&str -> String` manually
.string("string literal".to_owned())
// We have to convert `&str -> PathBuf` manually
.path_buf("string/literal".into())
// We have to convert `[u8; 4] -> IpAddr` manually
.ip_addr([127, 0, 0, 1].into())
.build();
Automatic enum wrapping
If you are working with enums a lot, you may implement the From<EnumVariant>
for your enum and avoid wrapping your enum variants when passing them to the builder. Pay attention to the difference in the focused code below.
use bon::builder;
#[builder]
fn evaluate(#[builder(into)] expr: Expr) { /* */ }
evaluate()
.expr(BinaryExpr {
/* */
})
.call();
enum Expr {
Binary(BinaryExpr),
Unary(UnaryExpr)
}
struct BinaryExpr { /* */ }
struct UnaryExpr { /* */ }
impl From<BinaryExpr> for Expr {
fn from(expr: BinaryExpr) -> Self {
Self::Binary(expr)
}
}
impl From<UnaryExpr> for Expr {
fn from(expr: UnaryExpr) -> Self {
Self::Unary(expr)
}
}
use bon::builder;
#[builder]
fn evaluate(expr: Expr) { /* */ }
evaluate()
.expr(Expr::Binary(BinaryExpr {
/* */
}))
.call();
enum Expr {
Binary(BinaryExpr),
Unary(UnaryExpr)
}
struct BinaryExpr { /* */ }
struct UnaryExpr { /* */ }
impl From<BinaryExpr> for Expr {
fn from(expr: BinaryExpr) -> Self {
Self::Binary(expr)
}
}
impl From<UnaryExpr> for Expr {
fn from(expr: UnaryExpr) -> Self {
Self::Unary(expr)
}
}
As you can see, the difference isn't significant in this case. It makes more sense when you have deeply nested enums.
Avoid Into
conversions
Performance-sensitive code
If allocations can pose a bottleneck for your application and you need to see every place in code where an allocation is performed, you should avoid using impl Into
overall. It can lead to implicitly moving data to the heap or cloning it.
Example:
use bon::builder;
#[builder]
fn process_heavy_json(#[builder(into)] data: String) { /* */ }
let json = String::from(
r#"{
"key": "Pretend this is a huge JSON string with hundreds of MB in size"
}"#
);
process_heavy_json()
// Whoops, we passed a `&String`.
// The builder will clone the data internally.
.data(&json)
.call();
The problem here is that we unintentionally passed a String
by reference instead of moving the ownership of the String
to process_heavy_json()
. This code implicitly uses this From
impl from the standard library.
Primitive numeric literals
impl Into
breaks type inference for numeric literal values. For example, the following code doesn't compile.
fn half(x: impl Into<u32>) -> u32 {
x.into() / 2
}
half(10);
The compile error is the following (Rust playground link):
half(10);
---- ^^ the trait `std::convert::From<i32>` is not implemented for `u32`,
| which is required by `{integer}: std::convert::Into<u32>`
|
required by a bound introduced by this call
The reason for this error is that rustc
can't infer the type for the numeric literal 10
because it could be one of the following types: u8
, u16
, u32
, which all implement Into<u32>
. There isn't a suffix like 10_u16
in this code to tell the compiler the type of the numeric literal 10
. When the compiler can't infer the type of a numeric literal it falls back to assigning the type i32
for an integer literal and f64
for a floating point literal. In this case i32
is inferred, which isn't convertible to u32
.
Requiring an explicit type suffix in numeric literals would be the opposite of good ergonomics that impl Into
is trying to achieve in the first place.
Weakened generics inference
If you have a function that returns a generic type, then the compiler needs to infer that generic type from usage unless it's specified explicitly. A classic example of such a function is str::parse()
or serde_json::from_str()
.
Example:
use bon::builder;
use std::net::IpAddr;
#[builder]
fn connect(ip_addr: IpAddr) { /* */ }
let ip_addr = "127.0.0.1".parse().unwrap();
connect()
.ip_addr(ip_addr)
.call();
Notice how we didn't add a type annotation for the variable ip_addr
. The compiler can deduce (infer) the type of ip_addr
because it sees that the variable is passed to the ip_addr()
setter method that expects a parameter of type IpAddr
. It's a really simple exercise for the compiler in this case because all the context to do it is there.
However, if you use an Into
conversion, not even Sherlock Holmes can answer the question "What type did you intend to parse?":
use bon::builder;
use std::net::IpAddr;
#[builder]
fn connect(ip_addr: IpAddr) { /* */ }
fn connect(#[builder(into)] ip_addr: IpAddr) { /* */ }
let ip_addr = "127.0.0.1".parse().unwrap();
connect()
.ip_addr(ip_addr)
.call();
In this case, there is a compile error:
error[E0284]: type annotations needed
|
9 | let ip_addr = "127.0.0.1".parse().unwrap();
| ^^^^^^^ ----- type must be known at this point
|
= note: cannot satisfy `<_ as std::str::FromStr>::Err == _`
help: consider giving `ip_addr` an explicit type
|
9 | let ip_addr: /* Type */ = "127.0.0.1".parse().unwrap();
| ++++++++++++
This is because now the ip_addr
setter looks like this:
fn ip_addr(self, value: impl Into<IpAddr>) -> ConnectBuilder<SetIpAddr<S>> { /* */ }
This signature implies that the value
parameter can be of any type that implements Into<IpAddr>
. There are several types that implement such a trait. Among them: Ipv4Addr
and Ipv6Addr
, and, obviously, IpAddr
itself (thanks to this blanket impl).
This means the setter for ip_addr
can no longer hint the compiler a single type that it accepts. Thus the compiler can't decide which type to assign to the ip_addr
variable in the original code, because there can be many that make sense. I.e. the code will compile if any of the Ipv4Addr
or Ipv6Addr
or IpAddr
type annotations are added to the ip_addr
variable, but the compiler has no right to decide which of them to use on your behalf.
This is the drawback of using not only impl Into
, but any generics at all.
None
literals inference
impl Into
breaks type inference for None
literals. For example, this code doesn't use Into
conversions and compiles fine:
use bon::Builder;
#[derive(Builder)]
struct Example {
member: Option<String>
}
Example::builder()
// Suppose we want to be explicit about omitting the `member`,
// so we intentionally invoke the `maybe_` setter and pass `None` to it
.maybe_member(None)
.build();
Now, let's enable an Into
conversion for the member
:
use bon::Builder;
#[derive(Builder)]
struct Example {
#[builder(into)]
member: Option<String>
}
Example::builder()
.maybe_member(None)
.build();
When we compile this code we get the following error:
.maybe_member(None)
------------ ^^^^ cannot infer type of the type parameter `T`
declared on the enum `Option`
The problem here is that the compiler doesn't know the complete type of the None
literal. It definitely knows that it's a value of type Option<_>
, but it doesn't know what type to use in place of the _
. There could be many potential candidates for the _
inside of the Option<_>
. This is because the signature of the maybe_member()
setter changed:
fn maybe_member(self, value: Option<String>) -> ExampleBuilder<SetMember<S>>
fn maybe_member(self, value: Option<impl Into<String>>) -> ExampleBuilder<SetMember<S>>
Before we enabled Into
conversions the signature provided a hint for the compiler because the setter expected a single concrete type Option<String>
, so it was obvious that the None
literal was of type Option<String>
.
However, after we enabled Into
conversions, the signature no longer provides a single concrete type. It says that it accepts an Option
of any type that implements Into<String>
.
It means that the None
literal could be of types Option<&str>
or Option<String>
, for example, so the compiler can't decide which one you meant. And this matters, because Option<&str>
and Option<String>
are totally different types. Simplified, Option<&str>
is 16 bytes in size and Option<String>
is 24 bytes, even when they are None
.
To work around this problem the caller would need to explicitly specify the generic parameter for the Option
type when passing the None
literal:
use bon::Builder;
#[derive(Builder)]
struct Example {
#[builder(into)]
member: Option<String>
}
Example::builder()
.maybe_member(None::<String>)
.build();
Code complexity
This is quite subjective, but impl Into<T>
is a bit harder to read than just T
. It makes the signature of the setter slightly bigger and requires you to understand what the impl Trait
does, and what its implications are.
If you want to keep your code simpler and more accessible (especially for beginner rustaceans), just avoid the Into
conversions.