Comment by foldr

3 years ago

> This is a good thing.

This is dogmatic. In can be a good thing sometimes, in some domains. In other instances it can just be a pain.

Let's say I'm using a 'names' library that provides the following utility function:

    first_name<'a>(name: &'a str) -> &'a str

My code happily uses this function (which we can assume is not performance critical). At some point, the author of the library notices that there are cultures where the nearest analogue of a 'first name' is not always a contiguous substring of the full name. To fix this bug they must change the function's type. They may choose e.g.:

    first_name<'a>(name: &'a str) -> Cow<'a, str>

I'd like to update my version of the library to get the bug fix, but can't do this for free. I have to update the calling code, and possibly even change some of my own internal data representations. This contrasts with pretty much any GCed language. For example, in Go the type of the function would just be

    func firstName(name string) string

and the bug fix would require no change in the API.

Now let's relate this example back to what you originally said in response to kaba0:

>> [kaba0:] A libraries next version which switches up some internal representations memory handling should ideally not mess up your application

> It doesn't have to because internal memory representation can and should be abstracted out, and Rust gives a plethora of tools to do that.

The above is a simple example of why this is not true. Any time you write a function that returns a reference, you are limiting the changes you can make to internal representations (both in the library itself and the calling code) without making a breaking API change.

Please don't respond by saying "this API was badly designed in the first place!" Most languages don't give you the opportunity to design APIs badly in this particular way. If all APIs were perfectly designed on day one then of course we'd never have to worry about API changes.

Again, none of this is to bash Rust. I just think it is important to be realistic about the downsides as well as the upsides of Rust's ownership system.

By writing:

    first_name<'a>(name: &'a str) -> &'a str

you promised the caller that the output is built directly from the input slice. That is your choice. I'll show you later that you didn't have to.

Similarly by writing this in Go:

    func firstName(name string) string

you promised the function accepts a string.

Then you want to change the semantics (the contract) of the function, break your promise and you complain you have to change the signature. You can't do that in any statically typed language.

In Go's case, if you suddently wanted to change the memory representation of name to something else than string, e.g. to a name struct:

    func firstName(first_name name) name

then obviously you have to change the signature. This is the same problem.

If you don't want your caller to be affected by lifetimes, just don't specify them in the signature:

    fn first_name(name: String) -> String

this is perfectly fine in Rust.

You may say it might be slow because it forces a copy, and forces a particular string implementation. So as I said, Rust gives you tools to abstract out the implementation details:

    fn first_name(name: impl AsRef<str>) -> impl AsRef<str> {
       // all are correct:
       // return name;
       // return "foo";
       // return String::from("foo");
    }

The flexibility goes actually much further than just relaxing the lifetimes. With this signature I actually can change the name representation from String to any type that can be exposed as a slice, with no additional runtime penalty like virtual calls, which you'd need otherwise in Go/Java.

> I just think it is important to be realistic about the downsides as well as the upsides of Rust's ownership system.

So the downside is that it offers you more choices and allows to express more constraints in the signatures, and you can choose wrong. I guess that's quite ok in a general purpose language that wants to be applicable to many different niches.

  • As I said in the previous comment:

    >Please don't respond by saying "this API was badly designed in the first place!" Most languages don't give you the opportunity to design APIs badly in this particular way. If all APIs were perfectly designed on day one then of course we'd never have to worry about API changes.

    I did edit in that paragraph ~5 minutes after submitting the comment, so apologies if you missed that.

    >fn first_name(name: impl AsRef<str>) -> impl AsRef<str> {

    As I said previously, "...unless there is to be a ban on functions returning simple references..." If you're willing to eat all that extra generic ceremony, then yes, you can make Rust APIs that are flexible w.r.t. ownership. However, you can't control the code style of the libraries that you're using.

    > With this signature I actually can change the name representation from String to any type that can be exposed as a slice, with no additional runtime penalty like virtual calls, which you'd need otherwise in Go/Java.

    Off topic, but you could do this in both Java and Go via generics.

    • > Please don't respond by saying "this API was badly designed in the first place!" Most languages don't give you the opportunity to design APIs badly in this particular way. If all APIs were perfectly designed on day one then of course we'd never have to worry about API changes.

      Ok, I agree. But this is just as well a problem for any language that specifies API in some way. So it is really a decades old debate about static typing disallowing to change things easily. I mean, there might always be certain case when the library author has to change the signature because they made it too restrictive. It just happens that Rust allows you to constrain things that might not be constrainable in other languages, so I guess the chances for that happening accidentally are somewhat higher. But generally I like going from more concrete / restricted to more generic when needed rather than the other way round.

      > Off topic, but you could do this in both Java and Go via generics.

      I don't think so you can achieve same level of flexibility and efficiency at the same time.

      Go doesn't have function level generics, so you'd need interfaces and runtime polymorphism as usual (and runtime penalty).

      And in Java you cannot add a new interface implementation to a builtin type like String, so the idea of switching from String to any other type won't work. There is much more upfront ceremony needed to add such flexibility (defining custom interface + implementations and using them from the beginning).

      3 replies →