-
-
Notifications
You must be signed in to change notification settings - Fork 2.7k
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
Update std.testing.expectEqual
and friends to use peer type resolution
#17431
Conversation
lib/std/mem.zig
Outdated
pub fn MinMaxResult(comptime T: type) type { | ||
return struct { min: T, max: T }; | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This type + IndexOfMinMaxResult
were required to be added after the change to make it possible to instantiate them. You couldn't use const Result = @TypeOf(minMax(u8, ""))
because each invocation of the old minMax
returned a new and distinct struct.
0a5be3e
to
0170481
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I generally think this change improves the usability of this library. Since the expected value is often a literal value, needing to type it explicitly is very annoying.
One potential downside of this is that peer type resolution might relax assertions of the return type of the function being tested, but that seems like a very minor downside. If such testing would still be meaningful, the explicit typing can return.
Has anyone explored using swapped typing, where instead of the second parameter taking the type of the first, the first parameter takes the type of the second? That's not possible in one line of zig, but with the two function approach here, it could be done. I'm not sure if that would make literal expressions work better, such as .{}
struct constructions or .name
enum literals.
Some of the changes in this PR seem like regressions, and I'd like further research to be done before giving my approval on this PR, but i wouldn't be opposed to this getting merged as-is.
@@ -1156,7 +1156,7 @@ test "cast function with an opaque parameter" { | |||
.func = @ptrCast(&Foo.funcImpl), | |||
}; | |||
c.func(c.ctx); | |||
try std.testing.expectEqual(foo, .{ .x = 101, .y = 201 }); | |||
try std.testing.expectEqual(Foo{ .x = 101, .y = 201 }, foo); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Does this work?
try std.testing.expectEqual(Foo{ .x = 101, .y = 201 }, foo); | |
try std.testing.expectEqual(.{ .x = 101, .y = 201 }, foo); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fails with
lib\std\testing.zig:57:15: error: incompatible types: 'struct{comptime x: comptime_int = 101, comptime y: comptime_int = 201}' and 'cast.test.cast function with an opaque parameter.Foo'
const T = @TypeOf(expected, actual);
^~~~~~~~~~~~~~~~~~~~~~~~~
lib\std\testing.zig:57:23: note: type 'struct{comptime x: comptime_int = 101, comptime y: comptime_int = 201}' here
const T = @TypeOf(expected, actual);
^~~~~~~~
lib\std\testing.zig:57:33: note: type 'cast.test.cast function with an opaque parameter.Foo' here
const T = @TypeOf(expected, actual);
^~~~~~
test\behavior\cast.zig:1159:32: note: called from here
try std.testing.expectEqual(.{ .x = 101, .y = 201 }, foo);
~~~~~~~~~~~~~~~~~~~~~~~^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That's pretty surprising. I wonder if peer type resolution is supposed to work for this, and this is a limitation of the language that is planned to be fixed someday. 🤔
Since the status quo uses the anonymous struct construction, I assume that expectEqualInner(@as(@TypeOf(actual), expected), actual)
or something like that would work, where the type of the actual parameter is the authoritative one.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Related accepted proposal? #16865
I don't know much about the compiler internals but I suspect that the literal is being demoted from an "anonymous struct type" to a "tuple with comptime fields" once it enters the function, which is why the coercion no longer can happen.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This one is actually currently supposed to work - this is a PTR bug/limitation. However, it wouldn't work once anonymous struct types are removed from Zig, which is an accepted proposal.
@@ -438,9 +438,9 @@ test "Type.Union" { | |||
}, | |||
}); | |||
var tagged = Tagged{ .signed = -1 }; | |||
try testing.expectEqual(Tag.signed, tagged); | |||
try testing.expectEqual(Tag.signed, @as(Tag, tagged)); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is a surprising change. Does peer type resolution not work for this? I would have thought this implicit case would be unnecessary if peer type resolution would work.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fails with
lib\std\testing.zig:58:32: error: coercion from enum 'type.test.Type.Union.Tag' to union 'type.test.Type.Union.Tagged' must initialize 'i32' field 'signed'
return expectEqualInner(T, expected, actual);
^~~~~~~~
test\behavior\type.zig:429:20: note: field 'signed' declared here
const Tagged = @Type(.{
^~~~~
test\behavior\type.zig:429:20: note: union declared here
const Tagged = @Type(.{
^~~~~
test\behavior\type.zig:441:28: note: called from here
try testing.expectEqual(Tag.signed, tagged);
~~~~~~~~~~~~~~~~~~~^~~~~~~~~~~~~~~~~~~~
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Whilst unions are able to coerce to their tag type, it is not desirable for PTR to be able to trigger such a coercion. Consider this code:
const U = union(enum) {
a: u32,
b: u32,
};
const x = if (foo) U{ .a = 1 } else .b;
It would be very unexpected if the type of x
here was the tag type of U
, silently dropping the payload!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Whilst unions are able to coerce to their tag type, it is not desirable for PTR to be able to trigger such a coercion. Consider this code:
const U = union(enum) { a: u32, b: u32, }; const x = if (foo) U{ .a = 1 } else .b;
It would be very unexpected if the type of
x
here was the tag type ofU
, silently dropping the payload!
I would actually expect it to drop the payload: since the else branch doesn't have one, there is no way the final result can contain the payload. Alternatively I could also understand if PTR yields an error in this case (although I'm not immediately convinced this is a good idea: intuitively if a==b
performs a comparison in terms of type T
, I'd expect this type to be the result of @TypeOf(a,b)
). But not yielding a union, I don't see how this makes sense.
pub fn indexOfMinMax(comptime T: type, slice: []const T) struct { index_min: usize, index_max: usize } { | ||
pub fn indexOfMinMax(comptime T: type, slice: []const T) IndexOfMinMaxResult { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This seems suspiciously like a regression. Does .{ .index_min = 0, .index_max = 6 }
not work for the test cases?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Doesn't work for the same reason as test/behavior/cast.zig
. You have to use reflection and get the type of the actual value and explicitly coerce to it, which is very inconvenient.
pub fn indexOfMinMax(comptime T: type, slice: []const T) struct { index_min: usize, index_max: usize } {
// ...
}
test "indexOfMinMax" {
const actual = indexOfMinMax(u8, "abcdefg");
try testing.expectEqual(@as(@TypeOf(actual), .{ .index_min = 0, .index_max = 6 }), actual);
}
Similar concerns were brought up in Discord a few weeks ago, and I pointed out that even the status quo
So if testing the exact types is important, you have to compare the actual types. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
it looks like limitations with peer type resolution and the upcoming changes to anonymous structs make the direction of this PR not strictly an improvement. I would like to suggest instead of the first parameter determining the type (master branch) and instead of both parameters determining the type (this PR), we should have the second parameter determine the type.
Am I wrong to believe that it would be the best of all cases in these stdlib test changes? (except for the Tag
/Tagged
case in test/behavior/type.zig
.)
Changing the implementation to pub inline fn expectEqual(expected: anytype, actual: anytype) !void {
- const T = @TypeOf(expected, actual);
+ const T = @TypeOf(actual);
return expectEqualInner(T, expected, actual);
} makes cases like this compile: const Foo = struct { x: usize, y: usize };
var actual: Foo = .{ .x = 10, .y = 20 };
_ = &actual;
try expectEqual(.{ .x = 10, .y = 20 }, actual); But I suspect that this will only fix this particular issue short-term and will stop working once #16865 is implemented. It also doesn't fix the issue with builtins invoking RLS so cases like these will still need const inf_u32: u32 = 0x7F800000;
try expectEqual(inf_u32, @as(u32, @bitCast(std.math.inf(f32)))); Changing the signature to You could flip the parameters from Neither of these two fix the builtin-with-RLS case for the
I respectfully disagree. If you check the second commit in this PR, there were fewer than 40 assertions that needed to be changed due to compile errors. If I grep the Zig compiler codebase for |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@castholm thanks for your patience. after catching up on the discussions in #4437 and #16865 , i see people have already put a lot of thought into the stuff that i'm coming up with just now. given the proposals that have been accepted, i think you're right, and this PR is the best improvement way forward that we have. (sorry it took so long for me to get there.)
thanks for your contributions! (I did not check what was failing in the CI.)
This commit changes the type of the second parameter to `anytype`, which should make it easier to pass literals to these functions. This change shouldn't *silently* break existing code (the assertions themselves should retain the same behavior as before) but it may result in some new compile errors when struct/union/array literals or builtins like `@bitCast` are used for the second argument. These compile errors can be fixed by explicitly coercing these expressions to the correct type using `@as`.
These are some spurious fixes to help illustrate the improved ergonomics of the `expectEqual` change. It is by no means complete.
Thank you @castholm. It's a nice solution and a nice writeup. |
This PR is an attempt of improving the ergonomics of passing literals to
std.testing.expectEqual
by changing the type of its second parameter toanytype
and coercing both arguments to a common type@TypeOf(expected, actual)
.The function is made
inline
to help ensure that certain coercions that can't happen at runtime happen at comptime, for example, from a value of the union's underlying enum type to an instance of the union itself.This PR offers a partial solution to the problem described in the body of #4437. I invite everyone to provide feedback and suggest alternative solutions, and the core Zig team is encouraged to close this PR if they feel it does not align with their vision for the standard library.
In addition to
expectEqual
, this PR also applies similar changes toexpectEqualDeep
,expectApproxEqAbs
andexpectApproxEqRel
.Motivation
Currently, the second parameter
actual
is of the type@TypeOf(expected)
which makes it difficult to pass literals like numbers ornull
as the first argument. Simple code that feels like it should "just work" results in compile errors due to the function trying to coerce the second argument either to a comptime-only type or a very conservative one such as@TypeOf(null)
. The reason for these compile errors is not always very obvious, especially for newcomers to the language, and every so often someone new will make a post in a Zig community confused why their assertions don't compile.As a result of this, the user is often required to explicitly coerce the first argument to the expected type by using
@as
which makes assertions more cumbersome to type and more difficult to read. It might also encourage the user to swap the order of the arguments which will result in incorrect log messages from failed assertions.Zig's
test
declarations is an excellet asset that makes writing tests a lot easier compared to other languages. In order to help encourage developers to write and maintain tests, test code should be ergonomic and easy to both write and read. The status quo ofexpectEqual
being difficult to use conflicts with those goals.The third commit demonstrates some cherry-picked cases where verbose
@as
was previously required but can now be removed.Does this change break existing code?
If the Zig code base is anything to go by, the vast majority of existing uses of
expectEqual
should continue to work and only a small number of existing uses will result in compile errors that require fixing.Because the previous signature was more strict and often required explicitly coercing the first argument to a specific type, I believe that this change should not silently break any existing uses of
expectEqual
. Assertions that pass prior to this change should continue to pass, and assertions that fail should continue to fail.This change does however loudly break a minor number of specific uses that will now result in compile errors. These mostly involve passing anonymous struct/union/enum literals and the results of builtins that invoke result locations semantics such as
@bitCast
as the second argument, which no longer resolve to an appropriate type because the parameter type is nowanytype
.Luckily, fixing these compile errors is as simple as specifying the struct/union/enum name or explicitly coercing the expressions by wrappping them in
@as
. The second commit in this PR fixes all the compile errors that were introduced to the Zig code base.Because the function has been made
inline
, it could in theory also break function pointers and assignments to function body types (resulting in compile errors), but I strongly doubt anyone is using the assertion functions like that.