Intro
In #35914, I made some tweaks to std.meta.hasUniqueRepresentation(T: type) that got me thinking about this API.
This function returns true if it can be determined that T has a unique bit pattern for each value. There are two reasons I'm aware of that a type may not have a unique bit pattern for each value:
- It may contain padding. Since padding is undefined, the padding bits can be varied without varying the value.
- It may contain floats.
NaN has multiple representations, as does zero.
Problem Statement
While the result of this function is comptime known, it is allowed to vary between builds, because it doesn't require that T have a well defined layout. For example, an untagged union might implicitly have a safety tag in a debug build that it loses in a release build, leading to padding in the debug build but not the release build.
This is a problem because it's easy to misuse in such a way as to write code that compiles in some optimization modes but not others.
Why not just require well defined layouts?
I initially thought that this was an oversight, and was going to add a check that T has a well defined layout to the function. However, this is actually at odds with about half of the usages in the compiler/standard library:
std.AutoHashMap and such accelerate hashing when a type has a unique representation by hashing the result of asBytes instead of iterating over the fields. Modifying this function to return false for non well defined layouts would likely pessimize our hash maps in release builds.
std.mem has a similar use case with similar tradeoffs for its searches.
std.Build.abi appears to use to use it to check that a type can be serialized. It first enforces that the type is extern compatible, which guarantees that it gets the same result for all optimization modes.
std.zig.Zoir has a similar use case with similar tradeoffs for its Header, Note, and CompileError type. It does not assert that these types are extern, but they are, so there's no issue here.
- As of #35914,
InternPool.zig uses this to assert that a simple hashing strategy is always correct for intern pool keys. Unlike hash map it has no fallback, but is still correct since it first asserts that the type is extern compatible.
Given that 50% of the cases are relying on this behavior it doesn't make sense to take it away. However, the other 50% of the cases are asserting that the layout is well defined as its required for those usages to be correct, and there's not a single catch all way to make this assert right now so they all use different strategies.
Additionally, in the use cases that don't involve hashing, there's no reason to disallow floats!
Proposal
This API is serving three distinct use cases right now. We should split it up such that each use case can be better served.
Here are the three use cases:
- Deciding on a hashing strategy.
- It's okay if the result varies between builds
- Floats should not be allowed
- Asserting that
hash(asBytes(ptr)) strategy is viable with no fallback
- Not okay to vary between builds
- Floats should not be allowed
- Asserting that serialized data has no padding
- Not okay to vary between builds
- Floats should be allowed
With these in mind, the following primitives would serve these use cases well:
hasUniqueRepresentation -> std.mem.reprUnique (same semantics, new name)
std.mem.reprDefined (new)
std.mem.reprUnpadded (new)
Usage would be as follows:
- Callers choosing between hashing strategies would continue to use
reprUnique (renamed from hasUniqueRepresentation)
- Callers asserting that a simple
asBytes style hash is correct would use assert(reprDefined(T) and reprUnique(T))
- Callers checking their serialized types would use
assert(reprDefined(T) and reprUnpadded(T))
Open Questions
Union vs Enum Tags
(削除) In #35914, @mlugg pointed out: (削除ここまで)
(削除) > [The] design of Zig does not require implementations to use the given tag type for the in-memory representation of a tagged union. For instance, a union(enum(u16)) with 256 or fewer fields could be represented in memory with a 1-byte tag---and this choice has no observable effects to the user, so is legal. (削除ここまで)
(削除) Is this also true for enums, or only for tagged unions? hasUniqueRepresentation currently assumes this is not true for enums. (削除ここまで)
(answered)
Explicit vs implicit tags
It's not currently possible to implement reprDefined in a useful way for packed structs, packed unions, or enums. These types are well defined if they have explicit tag types/backing ints, but std.lang.Type does not currently indicate whether the tag type/backing ints are explicit or implicit.
There are three ways we could resolve this. The first is to instead define this function as an assert. This works, but doesn't compose well with the other functions:
pubfnassertReprDefined(T:type)void{_=externstruct{well_defined_repr:T};};
The second is to modify std.lang.Type to include this information. This works, but we're ultimately just mirroring a bunch of logic that's already implemented in validateExtern in the compiler.
The third option is to expose a new building @reprDefined that shares code with Type.zig's validateExtern.
Future tooling to detect unsound memory operations
In #35914, @andrewrk pointed out:
I see, I suppose that is an OK definition of this function. This might bite us in the ass later though when we try to introduce tooling to find unsound operations on memory.
We can probably wait until we have such tools to resolve this, however, it's worth keeping this issue in mind because ideally we'd be able to introduce such tools without pessimizing hashing/searching, which might involve making tradeoffs about how exactly this check works.
Other Notes
# Intro
In #35914, I made some tweaks to `std.meta.hasUniqueRepresentation(T: type)` that got me thinking about this API.
This function returns true if it can be determined that `T` has a unique bit pattern for each value. There are two reasons I'm aware of that a type may not have a unique bit pattern for each value:
* It may contain padding. Since padding is undefined, the padding bits can be varied without varying the value.
* It may contain floats. `NaN` has multiple representations, as does zero.
# Problem Statement
While the result of this function is comptime known, it is allowed to vary between builds, because it doesn't require that `T` have a well defined layout. For example, an untagged union might implicitly have a safety tag in a debug build that it loses in a release build, leading to padding in the debug build but not the release build.
This is a problem because it's easy to misuse in such a way as to write code that compiles in some optimization modes but not others.
# Why not just require well defined layouts?
I initially thought that this was an oversight, and was going to add a check that `T` has a well defined layout to the function. However, this is actually at odds with about half of the usages in the compiler/standard library:
1. `std.AutoHashMap` and such accelerate hashing when a type has a unique representation by hashing the result of `asBytes` instead of iterating over the fields. Modifying this function to return false for non well defined layouts would likely pessimize our hash maps in release builds.
2. `std.mem` has a similar use case with similar tradeoffs for its searches.
3. `std.Build.abi` appears to use to use it to check that a type can be serialized. It first enforces that the type is extern compatible, which guarantees that it gets the same result for all optimization modes.
4. `std.zig.Zoir` has a similar use case with similar tradeoffs for its `Header`, `Note`, and `CompileError` type. It does not assert that these types are extern, but they are, so there's no issue here.
5. As of #35914, `InternPool.zig` uses this to assert that a simple hashing strategy is always correct for intern pool keys. Unlike hash map it has no fallback, but is still correct since it first asserts that the type is extern compatible.
Given that 50% of the cases are relying on this behavior it doesn't make sense to take it away. However, the other 50% of the cases are asserting that the layout *is* well defined as its required for those usages to be correct, and there's not a single catch all way to make this assert right now so they all use different strategies.
**Additionally, in the use cases that don't involve hashing, there's no reason to disallow floats!**
# Proposal
This API is serving three distinct use cases right now. We should split it up such that each use case can be better served.
Here are the three use cases:
1. Deciding on a hashing strategy.
* It's **okay** if the result varies between builds
* Floats **should not** be allowed
2. Asserting that `hash(asBytes(ptr))` strategy is viable with no fallback
* **Not okay** to vary between builds
* Floats **should not** be allowed
3. Asserting that serialized data has no padding
* **Not okay** to vary between builds
* Floats **should** be allowed
With these in mind, the following primitives would serve these use cases well:
* `hasUniqueRepresentation` -> `std.mem.reprUnique` (same semantics, new name)
* `std.mem.reprDefined` (new)
* `std.mem.reprUnpadded` (new)
Usage would be as follows:
* Callers choosing between hashing strategies would continue to use `reprUnique` (renamed from `hasUniqueRepresentation`)
* Callers asserting that a simple `asBytes` style hash is correct would use `assert(reprDefined(T) and reprUnique(T))`
* Callers checking their serialized types would use `assert(reprDefined(T) and reprUnpadded(T))`
# Open Questions
### Union vs Enum Tags
~~In #35914, @mlugg [pointed out](https://codeberg.org/ziglang/zig/pulls/35914#issuecomment-17998925):~~
~~> [The] design of Zig does not require implementations to use the given tag type for the in-memory representation of a tagged union. For instance, a union(enum(u16)) with 256 or fewer fields could be represented in memory with a 1-byte tag---and this choice has no observable effects to the user, so is legal.~~
~~Is this also true for enums, or only for tagged unions? `hasUniqueRepresentation` currently assumes this is *not* true for enums.~~
[(answered)](https://codeberg.org/ziglang/zig/issues/35944#issuecomment-18091973)
### Explicit vs implicit tags
It's not currently possible to implement `reprDefined` in a useful way for packed structs, packed unions, or enums. These types are well defined if they have explicit tag types/backing ints, but `std.lang.Type` does not currently indicate whether the tag type/backing ints are explicit or implicit.
There are three ways we could resolve this. The first is to instead define this function as an assert. This works, but doesn't compose well with the other functions:
```zig
pub fn assertReprDefined(T: type) void {
_ = extern struct { well_defined_repr: T };
};
```
The second is to modify `std.lang.Type` to include this information. This works, but we're ultimately just mirroring a bunch of logic that's already implemented in `validateExtern` in the compiler.
The third option is to expose a new building `@reprDefined` that shares code with `Type.zig`'s `validateExtern`.
### Future tooling to detect unsound memory operations
In #35914, @andrewrk [pointed out](https://codeberg.org/ziglang/zig/pulls/35914#issuecomment-18072224):
> I see, I suppose that is an OK definition of this function. This might bite us in the ass later though when we try to introduce tooling to find unsound operations on memory.
We can probably wait until we have such tools to resolve this, however, it's worth keeping this issue in mind because ideally we'd be able to introduce such tools without pessimizing hashing/searching, which might involve making tradeoffs about how exactly this check works.
# Other Notes
* [ ] The inline TODO in `hasUniqueRepresentation` should be resolved
* [ ] The documentation on `hasUniqueRepresentation` should be improved to mention that the result can vary, be more explicit that floats are excluded, and should probably use the phrase "if it can be determine" since it is currently allowed to return false negatives.