-
-
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
Proposal: remove Peer Type Resolution from the language #22182
Comments
Proposals - especially breaking ones - need to be evaluated asap so the language can be finished. However, 0.14.0 is too early since the release is imminent. |
Okay, noted -- apologies! |
Apology not accepted since it is unnecessary! I'm just trying to get better at communicating project management stuff. |
I think in support of this change is that this behavior already exists in a very similar situation: returning from a function. The return type of a function is always required, so distinct As an exercise, I went through ~2k lines of my own code trying to find places where this would break things, and found 3 items of note, at an average of one breaking change per 1k lines:
This does pose a problem, because this is a case where the error types being implied is really handy. I see a few options here:
|
@scottredig Thanks for the examples. For your error union example, I would generally suggest splitting that functionality off into a separate function with an IES, because it makes your intent clearer: callHandler(req) catch |err|
log.warn("Error {s} responding to request from {any} for {s}", .{ @errorName(err), req.conn.address, path });
};
// ...
fn callHandler(req: *Request) !void {
const embed_files = [_][]const u8{
"style.css",
"script.js",
"icon.svg",
};
const path = req.http.head.target;
if (std.mem.startsWith(u8, path, "/" ++ options.frame_path ++ "/")) {
return req.handleEmbed("index.html", "text/html");
}
inline for (embed_files) |embed_file| {
if (std.mem.eql(u8, path, "/__live_webserver/" ++ embed_file)) {
const mime_type = mime.extension_map.get(fs.path.extension(path)) orelse
.@"application/octet-stream"; // NOTE: I have something to say about this line, will explain below!
return req.handleEmbed(embed_file, @tagName(mime_type));
}
}
if (std.mem.eql(u8, path, "/__live_webserver/reload.js")) {
return req.handleReloadJs();
}
if (std.mem.eql(u8, path, "/__live_webserver/ws")) {
return req.handleWebsocket();
}
return req.handleFile();
} I've flagged up one interesting line there. Under a strict interpretation of this proposal, this line would no longer work, because the |
Breaking a block of code out to a function is one of the fundamental actions performed in coding. I think the opposite is equally important and should be ergonomically supported by the language. Aside from this proposal, I think keeping that block of code in the (I'll avoid derailing the conversation on justifying why I don't like this splitting out of the function. Happy to have the conversation elsewhere but it's an overly minor point of contention in the context of this proposal.)
Yeah I missed that line while doing a cursory read through. Nice catch. I honestly wouldn't mind putting a type here, and a slight change to |
My first reaction is that ZLS often yields the type hint 'either type' here, which interferes with language services on the variable, so I've been inclined to annotate those cases anyway. This example is from the Peer Type Resolution section of the docs, am I right in thinking this would still work because the return type coerces the strings? fn boolToStr(b: bool) []const u8 {
return if (b) "true" else "false";
} It would feel fairly heavyweight to use two |
@mnemnion You are correct, that would still work. Instead of the if returning |
Anyone following this issue: I just added a large section to the bottom of the original post, with some further thoughts I've had and a potential counter-proposal. Please give that a read if you're interested! |
How would this effect top level decls who's type differs based on the target, like what |
I'd call this an argument for the proposal, since I had figured that
This is a #3806 kibbitz, but then again, it's a #3806 heavy addendum: this illustrates why integer ranges should be closed intervals. This statement is much less confusing with (in order)
Is this entirely true? I figured it would exist as a type annotation for comptime parameters, at least. I suppose those could just become As a meta, I feel like two things are true: my comments here are reasonable responses to the added section of your proposal, and they also kinda don't belong here. Would it be reasonable to narrow this thread to removing Peer Type Resolution, and have another issue for changes to integer casting and coercion implied by #3806, and downstream of this one? There's clearly overlap, but I think it's fair to parse your counter-proposal as "remove Peer Type Resolution, but keep a comparable mechanism for numerics in order to support integer ranges", and I would think the result of the latter discussion could get folded into the PTR mechanism if the decision on #22182 is to keep it. I'm referring to the second two of your four added sections here, to me, they imply an additional issue which could host a focused discussion of the questions they raise. I think it's a good proposal, for the record, and that the main effect on user code would be to require some annotations which should be there anyway. |
By the wording of the proposal, IIUC, the following pattern (and similar ones) will no longer work: const Tracker = if(std.debug.runtime_safety) ResourceTracker else void;
tracker: Tracker = if(std.debug.runtime_safety) .init(0); // ".init" is "ResourceTracker.init", the implicit "else" branch returns a void value Is this meant/intended? Are comptime conditions exempt from PTR? |
Comptime-known branching is already exempt from PTR in status-quo - branches not taken are not semantically analyzed.
|
This may be the spiciest proposal I ever write.
Background
Peer Type Resolution (PTR) is a mechanism to combine a set of arbitrarily many types into a final type which all of the inputs can coerce to. For instance,
u8
andu16
peer resolve tou16
, while*volatile [4:0]u8
and*const [7:0]u8
peer resolve to[:0]const volatile u8
.The main purpose of PTR is to combine the results of different control flow branches. Opposing branches of a control flow construct are known as "peers", and PTR is applied to the results of those branches to figure out the result of the entire construct. For example:
The output here shows that the
if
statement applied Peer Type Resolution to the two "peer expressions"a
andb
.This issue will propose removing Peer Type Resolution from Zig. However, we must first note that Peer Type Resolution is also used in three other places in Zig.
+
and&
, including some builtins like@addWithOverflow
, apply PTR to their operands. This proposal does not propose changing these operators. It is likely that their typing rules will change in the future anyway (see allow integer types to be any range #3806, introduce a compile error for when a result location provides integer widening ambiguity #16310), but even if they don't, these operators require only a very small subset of the behavior of modern PTR.@TypeOf
builtin. In this case, the builtin will apply PTR to the types of the operands. This proposal does suggest removing this functionality.switch
ing on a tagged union, a prong containing multiple items with distinct payload types apply PTR to determine the type of the capture; e.g.switch (u) { .my_u32, .my_u16 => |u32_val| ... }
. This proposal does suggest removing this functionality.When we get into the meat of the proposal, I'll discuss these cases in a little more detail.
Problems with PTR
Peer Type Resolution, while commonly accepted as a part of Zig, actually has some problems.
Firstly, it leads to a common way for semantics to differ between runtime and comptime execution. For instance, consider this code:
This function is fairly straightforward, if quite esoteric. But the key point here is: what is the type of
opt
? Whenf
is called at runtime, the type is determined using PTR; the peers have types@Type(.null)
andu32
, which peer resolve to?u32
, soopt
has type?u32
. This makes the followingif
statement work as expected. However, iff
is called at comptime, PTR is not used, because the not-taken branch of the first conditional is not evaluated. This is a very useful feature of Zig, and not one that should change; but it means that the typeopt
is either@Type(.null)
oru32
, depending on the comptime-known value ofb
. In theu32
case (i.e.comptime f(false)
), this causes the secondif
statement to emit a compile error, becauseu32
is not an optional type, so this construct is invalid! So, this code emits a compile error when called at comptime.The second issue is that it can unintuitive impacts on comptime-only types. The statement
const x = if (b) 1 else 2
is invalid at runtime in Zig, because the resoved type ofx
iscomptime_int
, and its value depends on runtime control flow (theif
expression), so we have a value of a comptime-only type depending on runtime control flow, which is disallowed. However, this error goes away if one of the peers has a concrete integer type -- for instance,const x = if (b) 1 else @as(u32, 2)
works fine, because the first peer is coerced tou32
which can exist at runtime. This kind of "spooky action at a distance" can be confusing for new Zig users.Lastly, it can hinder readability. Consider these definitions:
What are the types of
x
andy
? If you said[:0]const u8
, you're actually wrong; that's the type ofx
, but the peers ofy
are strings of the same length, so the types peer resolve to*const [5:0]u8
. In this case, that's probably not a huge deal, but you can imagine it being more confusing when, say, integer widening is involved, or more significant pointer qualifiers likevolatile
. In these cases, it would be more clear to annotate the type of the variables. This can also help to clarify what properties of the type your code depends on; for instance, a user might annotate the type ofx
as[]const u8
, because the null terminator doesn't matter for their use case.Proposal
Remove Peer Type Resolution from the language. The features of Zig which utilize it are changed as follows:
if
expressions,switch
expressions, labeled blocks) emit a compile error if all peers are not of the same type (excludingnoreturn
peers).@TypeOf
accepts only one operand, making this proposal a reversal of @typeOf() should take multiple parameters and do peer type resolution #439.switch
on a tagged union requires all payloads to have the same type, making this proposal a reversal of when multiple union fields share a body in a switch prong, use a peer result type cast, rather than requiring identical types #2812.This change resolves all three of the issues described above:
if
/switch
with comptime-known operand #13025, which would be a complex language change to fix this issue. This would also solve Catch null in if-condition cails at comptime. #5462, which is the same issue.comptime_int
to implicitly become a runtime type due to the type of a peer; if you intend for the result to be e.g. au32
, you would have to coerce all peers. More realistically, you would annotate the type outside of the expression; more on this in a second.This proposal also simplifies the language in general, which is a nice plus.
The effect of this proposal on user code would be to encourage more type annotations in places where types are non-obvious. This style of including type annotations where possible is something Zig has been moving towards in recent years:
const x: T = .{ ... }
overconst x = T{ ... }
.const x: T = .foo
rather thanconst x = T.foo
.The advantages of explicit type annotations are as follows:
With all of these in mind, it's pretty clear that type annotations are a Good Thing, and I tend to support features which encourage more of them (within reason). I think this proposal probably falls within that category.
Impact on Real Code
This proposal will almost certainly cause a lot of breakage in the wild, including in the standard library. As I see it, the main question will be whether the diffs required to fix these breakages make code more or less readable. I strongly suspect the answer is that code will become more readable. However, I think we will have to implement this in the compiler (which would be relatively straightforward) and take a look at some of what breaks in a large codebase, probably the standard library and the compiler itself.
EDITS BELOW
Clarification: Result Types
This proposal never affects semantics when an expression has a result type. For instance, this code still works:
Here, even though the peers have types
comptime_int
andu16
, the result type ofu32
is propagated to these expressions and is applied before the values "exit" the conditional branch. This code working is actually a key motivation for this proposal: it encourages adding type annotations like this.Discussion:
catch
andorelse
Under this proposal as written, the following code would fail to compile:
That's because the
orelse
statement currently applies Peer Type Resolution to the typesE
and@Type(.enum_literal)
. Without PTR, these types would not match. The same applies tocatch
.However, if this proposal is accepted, this code actually can work; not through PTR, but by providing a result type to the RHS. If we call
?T
the type of the LHS after being evaluated, then the RHS can be evaluated with result typeT
; this is acceptable because under this proposal, it would need to have typeT
anyway for the peers to successfully combine. Again, the same thing applies tocatch
.To be honest, I could see an argument that this isn't desirable, and that the above snippet should indeed require a type annotation on
result
. But it's a possibility nonetheless.Discussion: Ranged Integers
One potential downside to this proposal is that it could make #3806 significantly more difficult to work with. For instance, consider this code:
Under #3806 with PTR,
y
has type@Int(0, 257)
, since PTR is applied to the peer types@Int(0. 256)
and@Int(1, 257)
. However, this proposal would cause this code to emit a compile error, because the peer types differ. That could be a big problem, since it could cancel out some of the benefits of implicit range expansion by requiring explicit type annotations.Assuming this is indeed awkward in practice, I'm not sure if there's a good way to reconcile these two proposals. This gives way to a counter-proposal...
Counter-proposal: Restrict PTR to Numeric Types
Instead of eliminating PTR altogether, we could potentially just nerf it a lot. Here's what I would suggest:
This refocuses PTR to be about combining numeric types. This restriction still solves the problems discussed in the original issue, whilst avoiding conflicting with #3806:
@TypeOf(expr)
. The latter could affect floating-point precision/rounding, but it seems reasonable that if you need precise details of one floating-point type, you should be annotating it anywhere where it's unclear.comptime_int
ceases to exist anyway, so this case where adding a runtime peer makes runtime evaluation work doesn't exist. It might still exist for floats if we allowcomptime_float
to peer resolve, which we probably should. This is a minor downside to this counter-proposal.The text was updated successfully, but these errors were encountered: