-
-
Notifications
You must be signed in to change notification settings - Fork 2.6k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
eliminate hidden pass-by-reference footguns #5973
Comments
Seems like the same thing is happening in #5455. |
I completely agree with the footgun sentiment, but defaulting to pass-by-value for arrays doesn't seem quite right. In C, an array is actually just a pointer so it can be passed by value without making a copy of the memory (and the compiler just straight-up ignores the specified size). It's a nice feature of Zig that you can pass an array argument and actually have the compiler enforce its size, but making a local copy of the memory is rarely the behavior that you'd want. |
I disagree. Zig arrays are "by value" in other contexts, like assignment. Also, if structs are passed by value but arrays are passed by reference, what do we do with a struct containing an array? Simpler if they are treated the same. You can pass a large struct, you can also pass a large array. You can also overflow the stack with a bunch of calls (although there's a plan to stop that at compile time). I think this is what you sign up for. C's treatment of arrays/pointers is an idiosyncracy, one which we don't need to inherit. |
Just for clarification: Zig doesn't define what values are passed by value or passed by reference which allows the compiler to choose either of those. This allows passing small values in registers and larger values via reference, no matter if the small value is a struct or a array[1] or a simple integer. The core problem here is that zig doesn't safety-check for aliasing when doing so and prevent compilation or do other measures to prevent that footgun |
This seems like a significant design flaw. Is it even possible for the compiler to flawlessly deduce whether something is being aliased? If not, I definitely think that pass-by-pointer should go back to being explicit, as in C. |
related: #12638 |
Somewhat simplified example which shows the different treating of primitives and structs: const std = @import("std");
fn f(a: i32, b: *i32) void {
b.* = 92;
std.debug.print("{}\n", .{a});
}
const S = struct { x: i32 };
fn g(a: S, b: *S) void {
b.x = 92;
std.debug.print("{}\n", .{a});
}
pub fn main() void {
var i: i32 = 0;
f(i, &i); // prints 0
var s: S = S{ .x = 0 };
g(s, &s); // prints 92
} Prints different results as of 0.11.0-dev.38+b40fc7018 |
Fairly obvious point, but it's worth noting that this issue is more subtle than aliasing of pointers directly in the function body: const std = @import("std");
const S = struct { val: u32 };
var glob: *S = undefined;
fn f(x: S) void {
g();
std.log.info("{}", .{x.val});
}
fn g() void {
glob.val = 42;
}
pub fn main() void {
var x: S = .{ .val = 0 };
glob = &x;
f(x); // prints 42
} I agree that it's not a good idea for this optimisation to change core and otherwise safe semantics like this, but I don't think the solution is to revert to C-style argument passing. Instead, I think functions should avoid passing by reference any value to which a mutable reference may exist; so any global |
I don't think this works good enough. This would undermine the promise that I don't need to worry about pass-by-value vs pass-by-reference. |
Just to check, are you referring to a case like this? fn foo(ally: std.mem.Allocator) !void {
const ptr = try ally.create(MyLargeType);
defer ally.destroy(ptr);
bar(ptr.*);
}
fn bar(x: MyLargeType) void {
_ = x; // ...
} Assuming you are: my intuition is that for types you're heap-allocating, you'd normally be passing them around by pointer anyway. That said, I agree this isn't ideal. One solution would be a way for
The problem with this approach is that opting out of the pass-by-ref becomes actually quite annoying: fn baz() void {
var x = MyLargeType.init();
global_for_later = &x;
// we don't want to pass x by reference here!
// but to avoid it, we need to make a copy, so a whole new const...
const x1 = x;
bar(x1);
// plus if we wanted to scope this more nicely we'd need a scope around that const+call - our 1-line call has suddenly become 4!
} This is a pretty gross usage for what should be a reasonable use case. In my mind, if we want to go the explicit route (rather than trying to make this an unnoticeable optimisation), we need a better way of annotating that a value should be copied (or, conversely, that it can definitely be passed by reference). One solution would be a new builtin A better route might be to go the other way: implement the optimisation in the way I discussed, but also add a |
To me, the most important thing here is to ensure this issue is fixed. If I have an argument like If the type is large and I want to avoid a copy, I think it is good enough to just leave the responsibility to the programmer and let the programmer pass For those two reasons, I fully agree with @mlugg saying:
|
That is correct with the exception of calling member functions, which can implicitly reference/dereference struct pointers. Overall the example would be const MyLargeType = struct {
...
fn bar(self: MyLargeType) void {
_ = self; // ...
}
};
fn foo(ally: std.mem.Allocator) !void {
const ptr = try ally.create(MyLargeType);
defer ally.destroy(ptr);
ptr.bar();
}
That wouldn't help much in the case of member functions, because the allocation might be further up in the callstack and has been passed as a pointer until the member function got called. There are also some further dificulties from member functions. Like for example
Yeah this is a big problem. Especially given that the cutoff between pass-by-value and pass-by-reference might be target dependent.
I agree. However I think we should choose the solution with the best performance. |
The member function problem that @IntegratedQuantum pointed is a real sack of knots, but if we try to untangle them we can take out the following:
My feeling is that passing an argument as Now, the problem is when and argument is passed as I think we can solve this only two ways, with a full ownership system, which we rule out or explicitness from the programmer. We already have Something like |
I was looking into Carbon language the other day, and they have the exact same way of passing in arguments. I want to share how they deal with this issue. In their design docs, this outlines parameters. It states:
If we look into value expressions it says that:
I think that this solution would be good, as it makes sure the default argument passing method is always the most efficient, but also notifies the user when an unexpected behavior occurs. In this way, we can and should treat the default parameter similar to If we go with a "I want to own the memory" 100% of the time fix, that is simply not possible without copying data (or using an ownership system). So in this way, we sacrifice performance and everyone goes to using So with the above implemented, the code would throw an error at |
I think the automatic pass-by-reference semantics is not a good design decision in my opinion. Since Zig is a low level language, behaviors like this should be explicit and not dependent on compiler optimizations. Without introducing anything new to the language, I would personally propose that the compiler should always pass structs and arrays by value. If a struct is too large to be copied, the compiler should warn the user, and tell the user that they should pass those structs by pointers. |
Two cents from a layman:
I think this is also problematic, when we consider multiple platforms, since each platform will have its own pass-by-value threshold (I believe?). For one platform, you might not get a warning, while for a different one you will. This is fine for your own exe-code, but warnings like that would be pointless for library code. Library code either always produces these warnings and we can't do anything about them ("unfixable" warnings), or we suppress them for external code, rendering this feature kinda useless.
I disagree. The compiler knows the target platform, and how the code is actually used, better than the programmer. It choosing the passing style is probably a good default, but there should be a way to opt-out and manually specify. We can already force pass-by-reference with |
Why are we calling this a "footgun"? Copy elision is entirely solved by the LLVM optimizer, but Zig is trying to reinvent it in the compiler frontend, in an ad-hoc, unsound fashion that clearly violates the value semantics of the language. It's a bug. To be clear: the result location semantics as explained in #12251 make sense for returning values, and can be sound with the right constraints. But this should just count as a miscompilation. |
Please don't just post inflammatory takes. This is an intentional part of the language design, and this issue documents the footgun it can cause. If you have run into this issue and can provide a useful data point, do so; otherwise, don't post comments which add nothing to the discussion. This issue is not related to Result Location Semantics. Instead, the feature leading to this footgun is Parameter Reference Optimization. Copy elision is not, as you assert, "entirely solved" by LLVM, because the cases which may cause footguns here are precisely the cases which any optimizer cannot optimize since semantics are not preserved. |
That's what I was trying to say, exactly because these two issues have been conflated in the past :P
I'm totally on board with language features to help address such cases. But current PRO isn't solving them, it just turns otherwise slow code into broken code. |
I should have measured before I spoke, sorry about that. While one could imagine a good optimizer exploiting |
I was reading about the Zig language and ended up here. IMHO, as others commented above, it should be explicit:
Due to other comment about the compiler limitation with copy (is this what everything is about?), I guess would then need to define a copy method when the structures are declared; I guess done automatically if the structure is simple, and with manual definition if a threshold is exceeded (due to compiler limitation). I think it is far more interesting to let the programmer know what is happening when reading/writing code, which cannot be done -intuitively, easily- with the current behavior of referencing if its a pointer, referencing if the compiler wants to, or copying if can be done, included when an array or other pointers' structure is passed (one just sees as passing by value if the type its not declared a few lines up). The same when the programmer is reading inside a function, or even method Self. The current behavior makes difficult to know what is happening and what will happen within the code, including when it is safe to release the memory (because it was referenced and not copied, even vice-versa with Self). For async and threads, it sounds like the current behavior is going to be an unnecessary additional hell that can be avoided. Turning it explicit will bring a great transparency and intuitiveness to Zig, what will avoid many hard problems to the users at future. I beg to think about it. |
Motivated by this talk about value semantics I thought to explore Zig's approach. In this example, the semantics of the language indicate that a struct is being passed by value, but the compiler instead passes the argument by const reference and inadvertently aliases the other argument, causing the program to generate an incorrect result. Using I think this is the correct issue to track this problem, but the solution does not appear straightforward. Either additional safety checks somehow, or no hidden reference semantics and leave it to the programmer. const std = @import("std");
const Number = struct {
x: i32,
};
fn add(a: *Number, b: Number) void {
a.x += b.x;
}
fn add_twice(a: *Number, b: Number) void {
add(a, b);
add(a, b);
}
pub fn main() !void {
var x: Number = .{ .x = 1 };
// Expect value semantics with the second argument but it is
// being passed by reference.
add_twice(&x, x);
// Expect x = 3
std.debug.print("x = {}\n", .{x.x}); // prints x = 4
}
// Zig versions:
// 0.13.0
// 0.14.0-dev.1417+242d268a0
// same in Debug, ReleaseSafe, ReleaseFast
// no change if a declared noalias
// Related issues:
// Safety-check noalias: https://github.com/ziglang/zig/issues/476
// https://github.com/ziglang/zig/issues/5973 |
I had a call with @andrewrk where we discussed and played around with some options regarding the future of PRO. Our conclusion was that PRO in its current form is fundamentally flawed, due to it causing uncheckable Illegal Behavior (in the case of aliasing, per this issue). We played around with some language-level alternatives, but nothing worked out well. So, here's our conclusion. PRO in its current form will cease to exist. The Zig compiler's optimizer will gain the ability to notice that a function is pure, and promote parameters to references accordingly. This will solve the use cases which PRO set out to solve: generic implementations of functions like It is not yet decided what the timeline is for implementing this change. |
this only refers to |
Correct, there are no plans to change that. |
if PRO is gone will function parameters become/have the ability to become mutable? |
No, there are no plans to make function parameters mutable. Parameters being immutable is still important to the "pure optimization" version of PRO, plus it aids clarity/readability. |
Accepted Proposal
Zig 0.6.0 (not master). This is related to, actually maybe a subset of, #4021 / #3696 (this issue doesn't involve result copy elision).
I understand that this was intended to be a feature of zig: args passed as values "may" be silently translated to pass-by-reference by the compiler. I think the intent was to stop the user from passing const pointers as an "optimization". But it's also a footgun, sort of like #2915. The problem occurs when you have another non-const pointer aliasing the same memory as the argument value.
The behavior here depends on the compiler implementation. It seems that right now, if
thing
is a struct value, it's passed by reference. But if it's a bareu32
, it's passed by value. I don't know if it will always be this simple (I assume there are plans to pass "small" structs by value.)The workaround for this situation is to make an explicit copy using a redundant-seeming optimization, probably accompanied by a comment explaining what's going on. Or else to restructure the code at a higher level, but then this footgun will still be lurking in the shadows.
I think that any optimistic "assume no aliases" optimization ought to be opt-in rather than opt-out. That would mean, either go back to the C way of things, or add a new syntax (some symbol that means "compiler can choose between value and const pointer"). Either way, a plain argument should always be passed by value. What do others think?
The text was updated successfully, but these errors were encountered: