Avoid aliasing decimal onto a private DecCalc layout via Unsafe.As
@aetos382 is already working on this.
Since Sep 17, 2026.
- Dominant language
- C#
- Stars
- 2.8k
- Forks
- 192
- PR merge metrics
- No merged PRs in 30d
Description
Summary
src/ZString/Number/DecimalEx.cs accesses the internal representation of decimal by declaring an explicit-layout struct that mirrors the runtime's private DecCalc and aliasing a live decimal onto it with Unsafe.As. I would like to propose moving away from this technique.
This is the mildest case of a pattern I found across several Cysharp repositories, so I am filing it for consistency rather than because I think it is likely to break tomorrow.
Affected code
https://github.com/Cysharp/ZString/blob/master/src/ZString/Number/DecimalEx.cs
[StructLayout(LayoutKind.Explicit)]
private struct DecCalc
{
// NOTE: Do not change the offsets of these fields. This structure must have the same layout as Decimal.
[FieldOffset(0)] public uint uflags;
[FieldOffset(4)] public uint uhi;
[FieldOffset(8)] public uint ulo;
[FieldOffset(12)] public uint umid;
[FieldOffset(8)] private ulong ulomidLE;
...
}
static ref DecCalc AsMutable(ref decimal d) => ref Unsafe.As<decimal, DecCalc>(ref d);
DecDivMod1E9 then mutates uhi / umid / ulo in place, i.e. it writes to a live decimal through the aliased struct.
Why this is risky
- No contract.
decimal's in-memory field order (flags, hi, lo, mid) is an implementation detail. It has been stable for a very long time and the runtime's ownDecCalcuses exactly this layout, so the practical risk is low — but it is not a documented guarantee, andulomidLEadditionally assumes little-endian ordering. - It fails silently.
Unsafe.Asperforms no validation. If the layout ever differed, the result would not be an exception but wrong numeric output — or, sinceDecDivMod1E9writes, a corrupteddecimal. - A public alternative exists.
decimal.GetBits(all targets) anddecimal.GetBits(decimal, Span<int>)/decimal.TryGetBits(.NET 5+) expose lo/mid/hi/flags as a documented contract, andnew decimal(int lo, int mid, int hi, bool isNegative, byte scale)reconstructs the value. Building a local working struct from those and writing the result back through the constructor gives the same arithmetic without aliasing the original value's memory — the only cost is that the mutation becomes explicit rather than in-place.
Suggested direction
Replace AsMutable(ref decimal) with a conversion built on decimal.GetBits / the decimal constructor: keep the internal DecCalc-style struct for the division helpers, but populate it from the public accessors instead of reinterpreting the caller's decimal, and return the result as a new decimal. On .NET 5+ targets the span overload makes this allocation-free.
If that is judged not worth the churn given how stable decimal is, I would suggest at least dropping LayoutKind.Explicit aliasing in favour of a one-time layout assertion in a test, so a future change is caught by CI rather than by wrong output.
Note
I found this while auditing R3, and the same or similar pattern (assuming the private field layout of a BCL type and reinterpreting it with Unsafe.As) exists in several other Cysharp repositories (R3, ZLogger, ZLinq, ObservableCollections, MemoryPack). I am filing one issue per repository rather than a single cross-repo issue, since the right fix differs per project.
Contributor guide
No contributing guide indexed for this repository
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Assessment
This issue has not been assessed yet.