Confusing evaluation order

The program below doesn't behave as I would have expected:

struct Whole {

  var parts: [String] = []

  mutating func insert(_ newPart: String) -> Int {
    let id = parts.count
    parts.append(newPart)
    return id
  }

  func describe(_ partID: Int) -> String {
    parts[partID]
  }

}

var whole = Whole()

// 1
let foo = whole.insert("Foo")
print(whole.describe(foo)) // OK

// 2
print(whole.describe(whole.insert("Bar"))) // Fail!?

Whole is a structure that contains some parts.Those are inserted with insert(_:), which returns some ID. Meanwhile describe(_:) returns a textural description of the part identified by the specified ID.

(1) works as expected. We're inserting a part and printing its description right after. To my surprise, though, (2) does not and crashes because of an out-of-bound error.

My instinct would lead me to believe it's a bug. Am I missing something?

3 Likes

What's the expected behaviour in this case? Work as in #1 or a compilation error?

Swift uses a strict left-to-right evaluation order in most situations. In this case, that causes the value of whole to be copied before the other arguments to describe are evaluated.

We’ve considered (CC @Andrew_Trick) changing the evaluation of this so that self is not evaluated by copy but instead by immutable borrow, which in this case would cause this code to not compile due to an exclusivity error when the variable is modified while being immutably borrowed.

10 Likes

Good mental model?

(whole.describe)(whole.insert("Foo"))

AKA

Whole.describe(whole)(whole.insert("Foo"))

(whole.insert)("Foo")

wouldn't work because you "Cannot reference 'mutating' method as function value".

That's what I was missing. That clears up my confusion, thanks!

Could parameters be executed first and then copy / borrow of the "whole" LHS variable done to call "description" on it? If that's possible it would match the behaviour of #1. Or is this bad for some reason?

BTW, this simplified version works ok in C++:

#include <assert.h>
#include <stdio.h>

struct Whole {
    int partCount = 0;
    int insert() {
        return partCount++;
    }
    void check(int index) {
        assert(index < partCount);
    }
};

int main() {
    Whole whole;
    whole.check(whole.insert());
    printf("done\n");
}

You can say that self arguments should be borrowed, and that would lead to something like what you’re saying. I think there’s a fair argument that that’s more like people’s naive mental model for self. It also would make mutating and non-mutating method calls more similar in their evaluation order. owned self (when allowed) would presumably have the current evaluation order.

If you go more complicated than that, it quickly becomes extremely difficult to reason about evaluation order, which would be very bad.

C++ always passes self (essentially) inout without an exclusivity rule, which is not something we would ever imitate.

2 Likes

I assume we wouldn't want to modify the evaluation order, but if that was on the table, then I believe there's no reason why we couldn't evaluate the arguments first, copy them, and only then borrow/copy the receiver.

A good argument for that evaluation order is that expressions of the form foo(bar()) do not compose well (or at least, intuitively) if foo evaluates before the bar(). Instead, we're compelled to store the result of the argument into a temporary.

2 Likes

Good catch!

How come class Whole behaves as expected :confused:

class Whole {

  var parts: [String] = []

  func insert(_ newPart: String) -> Int {
    let id = parts.count
    parts.append(newPart)
    return id
  }

  func describe(_ partID: Int) -> String {
    parts [partID]
  }
}
@main
struct ConfusingEvalOrder {
    static func main () async {
        print (type (of:Self.self), #function, "...")
               
        var whole = Whole()

        // 0
        print (whole.describe (whole.insert ("Jibber"))) // Ok

        // 1
        let foo = whole.insert ("Foo")
        print (whole.describe (foo)) // OK

        // 2
        print (whole.describe (whole.insert ("Bar"))) // Ok
    }
}

Due to struct being a value type, this surprising change in evaluation order feels very unnatural to say the least.

I hope that people who are maintaining the The Swift Programming Language Book will see @Alvae's original post and employ it as an example of pitfalls to avoid when using a struct with a mutating method.

1 Like

This is high on my list of things we should fix in Swift 6.
Three reasons that immutable methods should borrow self:

  • it matches typical programmer expectations that self refers to the caller's value, not a copy. Any (new) exclusivity violations are likely bugs in the original code

  • it lets the compiler remove copies in common cases where programmers don't expect copies

  • it supports method invocation on move-only values

20 Likes

I appreciate the clarity of this answer and the discussion, but wonder if it's conclusory in this case.

Parentheses establish precedence, and should here with method call, no?

One might say: well, the target/callee is treated as a argument (somewhat explicitly: "before the other arguments") - indeed, as the first argument.

What prevents us from treating it as the last argument, for evaluation purposes, since treating it as an argument is a compiler convention? Put another way, since it's first not because it's lexically first, but because the compiler has to pass it with other arguments, then still: why treat it as the first argument?

Yes, changing this order could cause unacceptable behavior changes, or it could mess with how arguments are handled in a call, assuming evaluation time is bound to memory/register position (?). But is there anything in Swift semantics/syntax that prohibits it?

If not, it seems better, no? Aside from matching the programmer's mental model better, it might offer optimization opportunities, since more would be known about the (other) parameters at the point the callee is evaluated, and we may have more luck with callee/target optimizations than (other) arguments.

Thanks (And sorry if I'm misunderstanding the situation.)

The self expression being evaluated before the argument expressions is part of Swift semantics.