How does File::open(...) take either a &str or Path type?

I've found that File::open is capable of taking a &str or a Path type: File in std::fs - Rust.

Looking at the docs, it's not entirely obvious why this is possible. How do I read the function signature of File::open and take away that it can take in a &str or Path?

pub fn open<P: AsRef<Path>>(path: P) -> Result<File>

Thanks!

1 Like

What the signature says is that open() takes a parameter of any type P, as long as P implements the AsRef<Path> trait. Which is to say, you can get access to parameter path in such a way that, when you call path.as_ref(), the result is a &Path.

3 Likes

It also accepts String or Arc<Path> or Cow<'_, OsStr>, and a bunch more. Basically you click on that AsRef trait in the signature (to get to its documentation) and look through the implementations of AsRef<Path> to find all the supported types. Note that the implementation of AsRef<U> for &T applies generically. This is why &str is supported, even though you'll only find an impl of AsRef<Path> for str listed, not &str.

6 Likes

I see. So File::open takes a Trait Object type as a parameter. In this case, that trait object is AsRef<Path> defined here.

The AsRef trait is implemented via one function:

pub trait AsRef<T> 
where
    T: ?Sized, 
{
    fn as_ref(&self) -> &T;
}

Where it immutably borrows the self type. So if we call:

File::open("myfile.txt");

At some point (I don't know when), &self.as_ref() is called on "myfile.txt" and it's converted to a Path type? It looks like that happens in the (source code)[path.rs - source]. A good takeaway here is looking at the implementors of trait objects!

Could you expand on this more? I wasn't able to follow:

Note that the implementation of AsRef<U> for &T applies generically. This is why &str is supported, even though you'll only find an impl of AsRef<Path> for str listed, not &str .

It's not a trait object -- that would be dyn AsRef<Path>.

This is a generic use of the AsRef trait. For fn open<P: AsRef<Path>>, each different type of P used with open in the program will create a new instantiation of that function, a distinct copy that is specialized just for that P.

https://doc.rust-lang.org/book/ch10-02-traits.html#traits-as-parameters

4 Likes

Not quite. A "trait object" would be if it took something involving dyn Path, but here's it's taking a generic -- essentially impl Path (though not spelled that way because of unimportant reasons).

1 Like

If T: AsRef<U>, then &T: AsRef<U> as well.

1 Like

Let’s do the full deduction to make sure that it’s fully understood.

The trait implementation in question is the one @quinedot was nice enough to just link above, let’s reproduce the full implementation (source)

impl<T: ?Sized, U: ?Sized> AsRef<U> for &T
where
    T: AsRef<U>,
{
    fn as_ref(&self) -> &U {
        <T as AsRef<U>>::as_ref(*self)
    }
}

and let’s also take a look at the function we’re trying to understand (source)

impl File {
    pub fn open<P: AsRef<Path>>(path: P) -> io::Result<File> {
        OpenOptions::new().read(true).open(path.as_ref())
    }
}

what both these things have in common are generic type parameters. In Rust, the variables listed in angle brackets after impl or after the name of a function in a function definition, (or in a few more places) are generic type parameters. These are universally quantified variables, universal quantification means it reads as “for all”; they also come with preconditions / “constraints” / “bounds” that either come directly after the variable introduction or separately in a where clause.

//   +----------+--- generic type parameters (introduced here, further
//   |          |    mentions of ‘T’ or ‘U’ to the right and below here
//   |          |    are referring to the same parameters)
//   |          |
//   |    +----- -----+---- bounds
//   |    |     |     |
//   v  vvvvvv  v  vvvvvv          
impl<T: ?Sized, U: ?Sized> AsRef<U> for &T
where
    T: AsRef<U>, // <--- additional constraint in a where clause
{
    fn as_ref(&self) -> &U {
        <T as AsRef<U>>::as_ref(*self)
    }
}

impl File {
    //          +-------------- generic type parameter (introduced here)
    //          |
    //          |       +--------- trait bound
    //          |       |
    //          v  vvvvvvvvvvv
    pub fn open<P: AsRef<Path>>(path: P) -> io::Result<File> {
        OpenOptions::new().read(true).open(path.as_ref())
    }
}

The placement in a where clause vs. on the argument itself doesn’t make a difference, so

impl<T: ?Sized, U: ?Sized> AsRef<U> for &T
where
    T: AsRef<U>,
{
    fn as_ref(&self) -> &U {
        <T as AsRef<U>>::as_ref(*self)
    }
}

is the same as

impl<T, U> AsRef<U> for &T
where
    T: ?Sized,
    U: ?Sized,
    T: AsRef<U>,
{
    fn as_ref(&self) -> &U {
        <T as AsRef<U>>::as_ref(*self)
    }
}

Also let’s ignore the topic of Sized vs ?Sized entirely in this post, so we’ll work with

impl<T, U> AsRef<U> for &T
where
    T: AsRef<U>,
{
    fn as_ref(&self) -> &U {
        <T as AsRef<U>>::as_ref(*self)
    }
}

impl File {
    pub fn open<P: AsRef<Path>>(path: P) -> io::Result<File> {
        OpenOptions::new().read(true).open(path.as_ref())
    }
}

The way to read these bounds/constraints is as a condition, e.g. with the phrasing “such that”. Then the open method reads as

For all types P such that the constraint P: AsRef<Path> is fulfilled, you get File::open::<P> as a function with signature fn(P) -> io::Result<File>

We can substitute in in concrete types into this statement. Considering that there’s this implementation

impl AsRef<Path> for String {
    fn as_ref(&self) -> &Path {
        Path::new(self)
    }
}

which has the effect that String: AsRef<Path> is fulfilled, we can apply the knowlege that “String is a type P such as P: AsRef<Path>” to derive that we have “File::open::String as a function with signature fn(String) -> io::Result)”.

Let me repeat in other words: We can consider String for P and the type signature of File::open to confirm that

  • since String is a type for which the constraint String: AsRef<Path> is fulfilled, we know we can get/use File::open::<String> as a function fn(String) -> io::Result<Path>.
  • Also, conveniently, Rust’s compiler offers type inference with the effect that if we have some s: String we don’t have to write File::open::<String>(s), but instead we can write File::open(s); the compiler fills in the missing “::<String>” for us.

In light of monomorphization, you can think of this generic File::open definition above a bit like a macro: You can use it to generate lots of (variants of) File::open functions by substituting appropriate types for P. These instantiations of File::open all use a copy of the same function body used to work with different concrete types. The trait bound makes sure that the function body always makes sense: The P: AsRef<Path> bound makes sure that there is something qualifying as an impl AsRef<Path> for P that defines a fitting as_ref() method for the type P, which is used for the call path.as_ref() in that function body.

By the way, if you’re calling ‘File::open::<String>’ at different places in your code, these multiple calls still refer to the same generated function. The compiler makes sure to generate only a single unique ‘File::open::<String>’ function, and in general there’s only a single unique ‘File::open::<P’ for each choice of P that’s actually used somewhere in your code. There’s no unnecessary overhead here; calling generic functions is not as bad as using actual macro expressions where the code is always duplicated for every call. Still other optimizations, in particular inlining may of course result in the code for ‘File::open::<String>’ to be replicated multiple times in case that function is inlined.


Having seen File::open as an example of a example of a generic function, let’s look at a generic trait implementation. The example of the impl AsRef<Path> for String was an example of a concrete (not generic) trait implementation. It defines an as_ref method for the Self-type String with return-type &Path.

By the way, since there’s lots of different as_ref-methods for lots of different types with different return types, there’s syntax for uniquely specifying (or “disambiguating”) this very as_ref method that’s defined in the impl AsRef<Path> for String from the standard library cited above. Its full name in Rust syntax is “<String as AsRef<Path>>::as_ref”. Similar to how we can write File::open(…) instead of File::open<…EXPLICIT TYPE…>(…) and Rust’s type inference helps us out, implicitly filling in the details, we can also simply write something like path.as_ref() like in the implementation of File::open without further specifying the types involved, since they’re clear from context. The method

impl File {
    pub fn open<P: AsRef<Path>>(path: P) -> io::Result<File> {
        OpenOptions::new().read(true).open(path.as_ref())
    }
}

equivalently, but using the more explicit notation, would look like

impl File {
    pub fn open<P: AsRef<Path>>(path: P) -> io::Result<File> {
        OpenOptions::new().read(true).open(<P as AsRef<Path>>::as_ref(&path))
    }
}

……anyways, I was going to talk about generic trait implementations:

Similar to how File::open defines for every type P (under certain conditions/constraints) a function File::open::<P>, Rust also offers generic, i.e. universally (“for all”/“for every”-) quantified trait implementations. The implementation

impl<T, U> AsRef<U> for &T
where
    T: AsRef<U>,
{
    fn as_ref(&self) -> &U {
        <T as AsRef<U>>::as_ref(*self)
    }
}

Can be read as

For any two1 types T and U such that the constraint T: AsRef<U> is fulfilled, you get a trait implementation impl AsRef<U> for &T.

1not necessarily distinct

Such a trait implementation impl AsRef<U> for &T then has the effect that the constraint &T: AsRef<U> is fulfilled. In other words, the generic implementation above implies that you know the following implication

If (for two1 types T and U) the constraint T: AsRef<U> is fulfilled, then the constraint &T: AsRef<U> is also fulfilled.

1not necessarily distinct


I was talking about how/that this relates to why File::open supports &str, i.e. why you can use it with P being &str, i.e. as a function File::open::<&str> with the signature fn(&str) -> io::Result<File>.

In order for us to be able to use a function File::open::<&str>, we need to make sure that &str: AsRef<Path> is fulfilled. Why? Remember the explanation of the generic function signature of File::open::<P>:

For all types P such that the constraint P: AsRef<Path> is fulfilled, you get File::open::<P> as a function with signature fn(P) -> io::Result<File>

which should actually, to be more accurate, be complemented with a second part of explanation, stating as much as

For all types P you get to use the function fn(P) -> io::Result<File> only if the constraint P: AsRef<Path> is fulfilled.

(By the way, the same kind of “only if” second part of the explanation is not something we get for generic trait implementations, since traits are open, i.e. there can be many trait implementations for the same trait; whereas a function like File::open only has a single definition, so it’s closed: there’s never a way to use File::open when the constraint P: AsRef<Path> is not fulfilled.)

Now to check (or “prove” if you like…) that &str: AsRef<Path> is fulfilled, it suffices to find an appropriate concrete or generic trait implementation. Luckily the generic trait implementation analyzed above can help out. Remember:

If (for two1 types T and U) the constraint T: AsRef<U> is fulfilled, then the constraint &T: AsRef<U> is also fulfilled.

With appropriate substitution of str for T and Path for U, we get in particulat

If the constraint str: AsRef<Path> is fulfilled, then the constraint &str: AsRef<Path> is also fulfilled.

Great, we’re trying to justify why the constraint &str: AsRef<Path> is fulfilled, looks like all we need to get there is to make sure std: AsRef<Path> is fulfilled. This in turn is justified by a concrete trait implementation in the standard library

impl AsRef<Path> for str {
    // details omitted……
}

There was a chain of reasoning here to get the the insight that &str can be passed to File::open, going through the definition of File::open which is a generic function, then through a generic trait implementation for &T to, finally, a concrete trait implementation. These chains of reasoning are very typical of Rust’s trait system and the compiler does this reasoning automatically for you; such a chain of trait implementations usually correlates directly with a chain (or sometimes a whole hierarchy/tree) of function calls being generated, i.e.

  • the implementation of File::open::<&str> calls <&str as AsRef<Path>>::as_ref
  • the implementation of <&str as AsRef<Path>>::as_ref calls <str as AsRef<Path>>::as_ref

These kinds of chains can get fairly long, fortunately the compiler can usually optimize away most or all the indirection/overhead introduced by these function calls, so you don’t have to worry that this complex trait system that Rust offers might be coming with much performance impact at run time.

7 Likes

Thanks for stepping me through Rust's trait reasoning chain! My apologies for the late reply, it took a few sessions to capture what you were saying and have a good understanding of what you were explaining. Seeing the concrete implementation "proving" that:

If (for two1 types T and U ) the constraint T: AsRef<U> is fulfilled, then the constraint &T: AsRef<U> is also fulfilled.

Was quite helpful and gives me more confidence in the language =)

I realize another aspect of confusion I had revolves around the generic trait implementation. This is the first time I've come across one of these in my Rust journey and took the time to try and understand what was happening. Clarifying that generic trait implementations are open was great =)

This topic was automatically closed 90 days after the last reply. We invite you to open a new topic if you have further questions or comments.