Dear all,
I recently had some discussions on the semantics of AsRef
and I would like to raise some questions in that matter. As shown later in this post, implementations in std
show some behavior that could be considered inconsistent or unwieldy. Of course, we cannot change std
, but I would like to explore all aspects of this issue anyway for the sake/case of
- a potential future "Rust 2.0",
- future existence of mechanisms to change
std
without breaking backwards compatibility, - improving documentation given the current situation,
- considering entanglement with future features like specialization.
Some questions are:
- When/how should a type implement
AsRef
? - When/how should an API be generic over
AsRef
? - When should
.as_ref()
be used and when&
,.borrow()
, or dereference (e.g.&*
)?
I do not want to challenge the importance of backwards compatibility here, but I would like to explore “what if” scenarios for the sake of better understanding semantics and limitations of AsRef
as it is defined now and as it could be defined.
That said, I would like to start off with some example code:
use std::borrow::Cow;
use std::ffi::OsStr;
use std::path::Path;
fn foo(_: impl AsRef<Path>) {}
fn bar(_: impl AsRef<i32>) {}
fn main() {
foo("Hi!");
foo("How are you?".to_string());
foo(&&&&&&("I'm fine.".to_string()));
foo(&&&&&&&&&&&&&&&&&&&"Still doing well here.");
//bar(5); // ouch!
//bar(&5); // hmmm!?
bar(Cow::Borrowed(&5)); // phew!
foo(Cow::Borrowed(OsStr::new("Okay, let me help!")));
foo(&&&&&&&&Cow::Borrowed(OsStr::new("This rocks!!")));
//foo(&&&&&&&&Cow::Borrowed("BANG!!"));
}
The commented-out lines fail to compile. (Playground)
We can see that AsRef<Path>
is implemented for:
str
String
&String
,&&String
,&&&String
, and so on&&&&&&&&Cow<OsStr>
(I'll get back to that later)
But we see that AsRef<Path>
is not implemented for:
Cow<str>
(or&Cow<str>
, etc.)
Moreover, AsRef<i32>
is not implemented for:
i32
&i32
(or&&i32
, etc.)
But AsRef<i32>
is implemented for:
Cow::Borrowed(&5)
This feels somewhat inconsistent or at least incomplete.
But vague feelings aside, we should try to carve out what's wrong here of if something is wrong here at all.
Let's try to look at conversions in std
and the language itself. We have:
- The borrow operators
&
and& mut
(technically also&&
and&& mut
due to existence of&&
as lazy "and" operator) - The dereference operator
*
- Explicit type casts using
as
- Coercions including:
Deref
coercion (or manually going throughDeref::deref
, which is not exactly the same (Playground)) and their immutable variants (DerefMut
)- unsized coercions
std::convert
with:std::borrow
with:
Currently, the documentation in std::convert
states the following:
- Implement the
AsRef
trait for cheap reference-to-reference conversions- Implement the
AsMut
trait for cheap mutable-to-mutable conversions- Implement the
From
trait for consuming value-to-value conversions- Implement the
Into
trait for consuming value-to-value conversions to types outside the current crate- […]
Generic Implementations
AsRef
andAsMut
auto-dereference if the inner type is a reference- […]
From
andInto
are reflexive, which means that all types can into themselves and from themselves
Moreover, documentation of AsRef
says:
Used to do a cheap reference-to-reference conversion. […] If you need to do a costly conversion it is better to implement
From
with type&T
or write a custom function.
AsRef
has the same signature asBorrow
, butBorrow
is different in a few aspects:Unlike
AsRef
,Borrow
has a blanket impl for anyT
, and can be used to accept either a reference or a value.Borrow
also requires thatHash
,Eq
andOrd
for a borrowed value are equivalent to those of the owned value. For this reason, if you want to borrow only a single field of a struct you can implementAsRef
, but notBorrow
.Note: This trait must not fail. […]
Generic Implementations
AsRef
auto-dereferences if the inner type is a reference or a mutable reference (e.g.:foo.as_ref()
will work the same if foo has type&mut Foo
or&&mut Foo
)
The key points regarding AsRef
here:
- reference-to-reference conversion
- conversion should be cheap
- "auto-dereferences if the inner type is a reference or mutable reference" (or in other words "
As
lifts over&
", see below) - converted type is not required to act equivalently regarding
Hash
,Eq
, andOrd
(for that,Borrow::borrow
can be used) - opposed to
From
andInto
,AsRef
andAsMut
are not reflexive, which is whylet _: &i32 = 0i32.as_ref()
fails
The lack of reflexivity isn't stated explicitly but follows from this implementation due to restrictions regarding overlapping implementations:
// As lifts over &
#[stable(feature = "rust1", since = "1.0.0")]
#[rustc_const_unstable(feature = "const_convert", issue = "88674")]
impl<T: ?Sized, U: ?Sized> const AsRef<U> for &T
where
T: ~const AsRef<U>,
{
#[inline]
fn as_ref(&self) -> &U {
<T as AsRef<U>>::as_ref(*self)
}
}
(See also Playground)
This implementation shall ensure that if T
implements AsRef<U>
, &T
will also implement AsRef<U>
. Why is that needed? One advantage is that it allows us to write a function like this:
use std::path::Path;
// unsightly syntax:
fn takes_path_unsightly<P: ?Sized + AsRef<Path>>(_path: &P) {}
// easier syntax:
fn takes_path<P: AsRef<Path>>(_path: P) {}
fn main() {
let s: &str = "ABC";
let string: String = s.to_owned();
takes_path_unsightly(s);
takes_path_unsightly(&string); // we need to borrow here, but that's not unusual
takes_path(s);
takes_path(string);
}
See also PR #23316 and this post on URLO by @quinedot.
However, not being automatically reflexive may come with disadvantages. When having a set of types (or even just two types) where cheap reference-to-reference conversion is desired between those (two of them), we end up with mostly redundant implementations like:
#[stable(feature = "rust1", since = "1.0.0")]
impl AsRef<str> for str {
#[inline]
fn as_ref(&self) -> &str {
self
}
}
(source)
Trivial functions like that need to be implemented for custom other types as well.
While references implement AsRef
according to the implementation of the pointed-to type (as said above, source), many generic smart pointers do not, such as Cow
(source), Rc
(source), Arc
(source), or even Box
(source). Regarding Box
, see also Playground. This seems to result in more manual/specific implementations such as:
#[stable(feature = "cow_os_str_as_ref_path", since = "1.8.0")]
impl AsRef<Path> for Cow<'_, OsStr> {
#[inline]
fn as_ref(&self) -> &Path {
Path::new(self)
}
}
(source)
But these implementations are endless. Note that std
does not implement AsRef<Path>
for Cow<'_, str>
for example:
use std::borrow::Cow;
use std::ffi::OsStr;
use std::path::Path;
fn takes_path<P: AsRef<Path>>(_: P) {}
fn main() {
takes_path(&OsStr::new("ABC"));
takes_path(Cow::Borrowed(OsStr::new("ABC")));
takes_path("ABC");
// fails:
takes_path(Cow::Borrowed("ABC"));
}
This was also one of the things demonstrated in the very first example of this post.
I would like to get back to some questions of the beginning of this post:
- When/how should a type implement
AsRef
?- […]
- When should
.as_ref()
be used and when&
,.borrow()
, or dereference (e.g.&*
)?
I would like to make a few hypotheses in that matter:
- Any type
T: ?Sized
may always implementAsRef<T>
with a trivial implementation (but it's not provided automatically). - A type
T: ?Sized
should implementAsRef<T>
with a trivial implementation if it's part of a set of types that can be converted into each other using cheap reference-to-reference conversion (throughAsRef
). - A generic smart pointer
T
should ideally not implementAsRef<<T as Deref>::Target>
but instead implementAsRef<U>
where<T as Deref>::Target: AsRef<U>
. Note that this differs from whatstd
currently does. - Going from a reference or generic smart pointer to the pointee should not (and sometimes cannot) be done through
AsRef::as_ref
, but through&*
or.borrow()
, where the latter gurantees equivalentHash
/Eq
/Ord
implementations but might sometimes require type annotations to aid type inference, though.
I believe that this would (hypothetically speaking!) fix several inconsistencies that currently exist, but I lack overview to really be sure and thus would like to start a discusson on that matter.
The issue seems to come up repeatedly, and I also stumbled upon it. See also the following issues and PRs on GitHub:
- #39397: Make AsRef and AsMut reflexive
- #45742: Blanket impl of AsRef for Deref (and corresponding FIXME in
core/src/convert/mod.rs
) - #73390: impl AsRef for Cow<'_, str>
- #98905: Cow<'_, T> should not implement AsRef but AsRef where T: AsRef
Of course, we cannot simply change implementations in std
and even adding some concrete implementations (such as suggested in #73390) can break code. So given the current situation, perhaps different recommendations may be better than those stated above (as hypotheses). I would like to discuss both the ideal scenario as well as the "Rust 1.x" case. In either case, there should be more clarity on what AsRef
does and how it's used, and ideally the documentation could be extended in that matter.