From a627f41eba086b1413460e3172cd43be5b6eb672 Mon Sep 17 00:00:00 2001 From: Marcel Roozerkans Date: Mon, 18 May 2026 09:39:28 +0200 Subject: [PATCH 1/8] chore(gen): add IsFormattable/IsStringType/IsValueType helpers --- .../Writers/SourceWriter.cs | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/src/ZeroAlloc.ValueObjects.Generator/Writers/SourceWriter.cs b/src/ZeroAlloc.ValueObjects.Generator/Writers/SourceWriter.cs index 6c39ba3..a49806e 100644 --- a/src/ZeroAlloc.ValueObjects.Generator/Writers/SourceWriter.cs +++ b/src/ZeroAlloc.ValueObjects.Generator/Writers/SourceWriter.cs @@ -6,6 +6,33 @@ namespace ZeroAlloc.ValueObjects.Generator.Writers; internal static class SourceWriter { + private static readonly System.Collections.Generic.HashSet s_formattable = new(System.StringComparer.Ordinal) + { + // Keyword form (default Roslyn ToDisplayString for primitives). + "int", "long", "short", "byte", "uint", "ulong", "ushort", "sbyte", + "float", "double", "decimal", + // FQN form (defensive — covers non-keyword BCL formattables and any future format change). + "System.Int32", "System.Int64", "System.Int16", "System.Byte", + "System.UInt32", "System.UInt64", "System.UInt16", "System.SByte", + "System.Single", "System.Double", "System.Decimal", + "System.DateTime", "System.DateTimeOffset", + "System.DateOnly", "System.TimeOnly", "System.TimeSpan", + "System.Guid", "System.Numerics.BigInteger", + }; + + private static bool IsFormattable(string typeName) => s_formattable.Contains(typeName); + + private static bool IsStringType(string typeName) + => string.Equals(typeName, "string", System.StringComparison.Ordinal) + || string.Equals(typeName, "System.String", System.StringComparison.Ordinal); + + private static bool IsValueType(string typeName) + => IsFormattable(typeName) + || string.Equals(typeName, "bool", System.StringComparison.Ordinal) + || string.Equals(typeName, "char", System.StringComparison.Ordinal) + || string.Equals(typeName, "System.Boolean", System.StringComparison.Ordinal) + || string.Equals(typeName, "System.Char", System.StringComparison.Ordinal); + public static string Write(ValueObjectModel model) { var sb = new StringBuilder(); From 22e34c62729b7eb439e837c2f4df91218daa330b Mon Sep 17 00:00:00 2001 From: Marcel Roozerkans Date: Mon, 18 May 2026 09:39:52 +0200 Subject: [PATCH 2/8] chore(gen): add ChooseToStringExpr helper for single-property ToString emit --- .../Writers/SourceWriter.cs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/ZeroAlloc.ValueObjects.Generator/Writers/SourceWriter.cs b/src/ZeroAlloc.ValueObjects.Generator/Writers/SourceWriter.cs index a49806e..c72512c 100644 --- a/src/ZeroAlloc.ValueObjects.Generator/Writers/SourceWriter.cs +++ b/src/ZeroAlloc.ValueObjects.Generator/Writers/SourceWriter.cs @@ -33,6 +33,21 @@ private static bool IsValueType(string typeName) || string.Equals(typeName, "System.Boolean", System.StringComparison.Ordinal) || string.Equals(typeName, "System.Char", System.StringComparison.Ordinal); + private static string ChooseToStringExpr(EqualityProperty p) + { + if (IsStringType(p.TypeName)) + return p.IsNullable ? $"{p.Name} ?? \"\"" : p.Name; + + if (IsFormattable(p.TypeName)) + return p.IsNullable + ? $"{p.Name}?.ToString(global::System.Globalization.CultureInfo.InvariantCulture) ?? \"\"" + : $"{p.Name}.ToString(global::System.Globalization.CultureInfo.InvariantCulture)"; + + return p.IsNullable + ? $"{p.Name}?.ToString() ?? \"\"" + : $"{p.Name}.ToString()"; + } + public static string Write(ValueObjectModel model) { var sb = new StringBuilder(); From 6c4fccec8df061010f6d9b9563e2118f0b3b882f Mon Sep 17 00:00:00 2001 From: Marcel Roozerkans Date: Mon, 18 May 2026 09:41:04 +0200 Subject: [PATCH 3/8] feat(gen): emit Value.GetHashCode() directly for single-property ValueObject --- .../Writers/SourceWriter.cs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/ZeroAlloc.ValueObjects.Generator/Writers/SourceWriter.cs b/src/ZeroAlloc.ValueObjects.Generator/Writers/SourceWriter.cs index c72512c..27c8033 100644 --- a/src/ZeroAlloc.ValueObjects.Generator/Writers/SourceWriter.cs +++ b/src/ZeroAlloc.ValueObjects.Generator/Writers/SourceWriter.cs @@ -121,6 +121,18 @@ private static void WriteGetHashCode(StringBuilder sb, ValueObjectModel model) { sb.AppendLine(" return 0;"); } + else if (model.Properties.Count == 1) + { + var p = model.Properties[0]; + // For known value types (incl. Nullable), .GetHashCode() is null-safe by construction. + // For reference types, the value can be null at runtime even when not nullable-annotated — + // use the null-conditional form unconditionally for safety. The JIT inlines this away + // for the non-null fast path. + var expr = IsValueType(p.TypeName) + ? $"{p.Name}.GetHashCode()" + : $"{p.Name}?.GetHashCode() ?? 0"; + sb.AppendLine($" return {expr};"); + } else if (model.Properties.Count <= 8) { var args = string.Join(", ", model.Properties.Select(p => p.Name)); From 86478ee5b2ec031c57675646ef15fd7b0bc6bbcd Mon Sep 17 00:00:00 2001 From: Marcel Roozerkans Date: Mon, 18 May 2026 09:41:53 +0200 Subject: [PATCH 4/8] feat(gen): emit bare ToString() for single-property ValueObject --- .../Writers/SourceWriter.cs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/ZeroAlloc.ValueObjects.Generator/Writers/SourceWriter.cs b/src/ZeroAlloc.ValueObjects.Generator/Writers/SourceWriter.cs index 27c8033..6e5c1a7 100644 --- a/src/ZeroAlloc.ValueObjects.Generator/Writers/SourceWriter.cs +++ b/src/ZeroAlloc.ValueObjects.Generator/Writers/SourceWriter.cs @@ -175,6 +175,13 @@ private static void WriteToString(StringBuilder sb, ValueObjectModel model) return; } + if (model.Properties.Count == 1) + { + var expr = ChooseToStringExpr(model.Properties[0]); + sb.AppendLine($" public override string ToString() => {expr};"); + return; + } + var parts = string.Join(", ", model.Properties.Select(p => p.Name + " = {" + p.Name + "}")); sb.AppendLine(" public override string ToString() => $\"" + model.TypeName + " {{ " + parts + " }}\";"); } From c1d1b63e63eee9378703d3f8add6508d8ba8d5cb Mon Sep 17 00:00:00 2001 From: Marcel Roozerkans Date: Mon, 18 May 2026 09:43:36 +0200 Subject: [PATCH 5/8] test(gen): regenerate single-property ValueObject snapshots --- ...Equality_ForTypeInGlobalNamespace#GlobalType.g.verified.cs | 4 ++-- ...atesEquality_ExcludingIgnoredMembers#Product.g.verified.cs | 4 ++-- ...esClass_ForSingleStringProperty#EmailAddress.g.verified.cs | 4 ++-- ...sClass_WhenForceClassIsTrue_OnStruct#OrderId.g.verified.cs | 4 ++-- ...esReadonlyStruct_ForStructDeclaration#Amount.g.verified.cs | 4 ++-- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/tests/ZeroAlloc.ValueObjects.Tests/GeneratorTests/EdgeCaseTests.GeneratesEquality_ForTypeInGlobalNamespace#GlobalType.g.verified.cs b/tests/ZeroAlloc.ValueObjects.Tests/GeneratorTests/EdgeCaseTests.GeneratesEquality_ForTypeInGlobalNamespace#GlobalType.g.verified.cs index 2340eef..53bd38a 100644 --- a/tests/ZeroAlloc.ValueObjects.Tests/GeneratorTests/EdgeCaseTests.GeneratesEquality_ForTypeInGlobalNamespace#GlobalType.g.verified.cs +++ b/tests/ZeroAlloc.ValueObjects.Tests/GeneratorTests/EdgeCaseTests.GeneratesEquality_ForTypeInGlobalNamespace#GlobalType.g.verified.cs @@ -13,11 +13,11 @@ other is not null && public override int GetHashCode() { - return System.HashCode.Combine(Value); + return Value?.GetHashCode() ?? 0; } public static bool operator ==(GlobalType? left, GlobalType? right) => left is null ? right is null : left.Equals(right); public static bool operator !=(GlobalType? left, GlobalType? right) => !(left == right); - public override string ToString() => $"GlobalType {{ Value = {Value} }}"; + public override string ToString() => Value; } diff --git a/tests/ZeroAlloc.ValueObjects.Tests/GeneratorTests/EqualityMemberAttributeTests.GeneratesEquality_ExcludingIgnoredMembers#Product.g.verified.cs b/tests/ZeroAlloc.ValueObjects.Tests/GeneratorTests/EqualityMemberAttributeTests.GeneratesEquality_ExcludingIgnoredMembers#Product.g.verified.cs index e563e56..22bb058 100644 --- a/tests/ZeroAlloc.ValueObjects.Tests/GeneratorTests/EqualityMemberAttributeTests.GeneratesEquality_ExcludingIgnoredMembers#Product.g.verified.cs +++ b/tests/ZeroAlloc.ValueObjects.Tests/GeneratorTests/EqualityMemberAttributeTests.GeneratesEquality_ExcludingIgnoredMembers#Product.g.verified.cs @@ -13,11 +13,11 @@ other is not null && public override int GetHashCode() { - return System.HashCode.Combine(Name); + return Name?.GetHashCode() ?? 0; } public static bool operator ==(Product? left, Product? right) => left is null ? right is null : left.Equals(right); public static bool operator !=(Product? left, Product? right) => !(left == right); - public override string ToString() => $"Product {{ Name = {Name} }}"; + public override string ToString() => Name; } diff --git a/tests/ZeroAlloc.ValueObjects.Tests/GeneratorTests/SinglePropertyStructTests.GeneratesClass_ForSingleStringProperty#EmailAddress.g.verified.cs b/tests/ZeroAlloc.ValueObjects.Tests/GeneratorTests/SinglePropertyStructTests.GeneratesClass_ForSingleStringProperty#EmailAddress.g.verified.cs index 00fb5c8..32b5bd6 100644 --- a/tests/ZeroAlloc.ValueObjects.Tests/GeneratorTests/SinglePropertyStructTests.GeneratesClass_ForSingleStringProperty#EmailAddress.g.verified.cs +++ b/tests/ZeroAlloc.ValueObjects.Tests/GeneratorTests/SinglePropertyStructTests.GeneratesClass_ForSingleStringProperty#EmailAddress.g.verified.cs @@ -13,11 +13,11 @@ other is not null && public override int GetHashCode() { - return System.HashCode.Combine(Value); + return Value?.GetHashCode() ?? 0; } public static bool operator ==(EmailAddress? left, EmailAddress? right) => left is null ? right is null : left.Equals(right); public static bool operator !=(EmailAddress? left, EmailAddress? right) => !(left == right); - public override string ToString() => $"EmailAddress {{ Value = {Value} }}"; + public override string ToString() => Value; } diff --git a/tests/ZeroAlloc.ValueObjects.Tests/GeneratorTests/SinglePropertyStructTests.GeneratesClass_WhenForceClassIsTrue_OnStruct#OrderId.g.verified.cs b/tests/ZeroAlloc.ValueObjects.Tests/GeneratorTests/SinglePropertyStructTests.GeneratesClass_WhenForceClassIsTrue_OnStruct#OrderId.g.verified.cs index 7427576..570679a 100644 --- a/tests/ZeroAlloc.ValueObjects.Tests/GeneratorTests/SinglePropertyStructTests.GeneratesClass_WhenForceClassIsTrue_OnStruct#OrderId.g.verified.cs +++ b/tests/ZeroAlloc.ValueObjects.Tests/GeneratorTests/SinglePropertyStructTests.GeneratesClass_WhenForceClassIsTrue_OnStruct#OrderId.g.verified.cs @@ -13,11 +13,11 @@ other is not null && public override int GetHashCode() { - return System.HashCode.Combine(Value); + return Value.GetHashCode(); } public static bool operator ==(OrderId? left, OrderId? right) => left is null ? right is null : left.Equals(right); public static bool operator !=(OrderId? left, OrderId? right) => !(left == right); - public override string ToString() => $"OrderId {{ Value = {Value} }}"; + public override string ToString() => Value.ToString(global::System.Globalization.CultureInfo.InvariantCulture); } diff --git a/tests/ZeroAlloc.ValueObjects.Tests/GeneratorTests/SinglePropertyStructTests.GeneratesReadonlyStruct_ForStructDeclaration#Amount.g.verified.cs b/tests/ZeroAlloc.ValueObjects.Tests/GeneratorTests/SinglePropertyStructTests.GeneratesReadonlyStruct_ForStructDeclaration#Amount.g.verified.cs index 4d8ba5e..05abaa7 100644 --- a/tests/ZeroAlloc.ValueObjects.Tests/GeneratorTests/SinglePropertyStructTests.GeneratesReadonlyStruct_ForStructDeclaration#Amount.g.verified.cs +++ b/tests/ZeroAlloc.ValueObjects.Tests/GeneratorTests/SinglePropertyStructTests.GeneratesReadonlyStruct_ForStructDeclaration#Amount.g.verified.cs @@ -12,11 +12,11 @@ public bool Equals(Amount other) => public override int GetHashCode() { - return System.HashCode.Combine(Value); + return Value.GetHashCode(); } public static bool operator ==(Amount left, Amount right) => left.Equals(right); public static bool operator !=(Amount left, Amount right) => !left.Equals(right); - public override string ToString() => $"Amount {{ Value = {Value} }}"; + public override string ToString() => Value.ToString(global::System.Globalization.CultureInfo.InvariantCulture); } From 8b2ae8dc32b10e12b6c7994ae1e610b763adbeb0 Mon Sep 17 00:00:00 2001 From: Marcel Roozerkans Date: Mon, 18 May 2026 09:46:34 +0200 Subject: [PATCH 6/8] test(gen): add explicit unit tests for single-property emit behavior matrix --- .../SinglePropertyEmitTests.cs | 108 ++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 tests/ZeroAlloc.ValueObjects.Tests/FunctionalTests/SinglePropertyEmitTests.cs diff --git a/tests/ZeroAlloc.ValueObjects.Tests/FunctionalTests/SinglePropertyEmitTests.cs b/tests/ZeroAlloc.ValueObjects.Tests/FunctionalTests/SinglePropertyEmitTests.cs new file mode 100644 index 0000000..f71607e --- /dev/null +++ b/tests/ZeroAlloc.ValueObjects.Tests/FunctionalTests/SinglePropertyEmitTests.cs @@ -0,0 +1,108 @@ +using System.Globalization; +using System.Threading; +using ZeroAlloc.ValueObjects; + +#pragma warning disable MA0048 // File name must match type name — fixture types co-located with their tests. + +namespace ZeroAlloc.ValueObjects.Tests.FunctionalTests; + +[ValueObject] +public readonly partial struct IntVo +{ + public int Value { get; } + public IntVo(int value) => Value = value; +} + +[ValueObject] +public readonly partial struct StringVo +{ + public string Value { get; } + public StringVo(string value) => Value = value; +} + +[ValueObject] +public readonly partial struct NullableStringVo +{ + public string? Value { get; } + public NullableStringVo(string? value) => Value = value; +} + +[ValueObject] +public readonly partial struct DateTimeVo +{ + public System.DateTime Value { get; } + public DateTimeVo(System.DateTime value) => Value = value; +} + +[ValueObject] +public partial class GreetingVo +{ + public string Name { get; init; } = ""; + public int Times { get; init; } +} + +public class SinglePropertyEmitTests +{ + [Fact] + public void IntValueObject_ToString_ReturnsInvariantCultureDigits() + { + var id = new IntVo(42); + Assert.Equal("42", id.ToString()); + } + + [Fact] + public void StringValueObject_ToString_ReturnsRawString() + { + var vo = new StringVo("hello"); + Assert.Equal("hello", vo.ToString()); + } + + [Fact] + public void NullableStringValueObject_ToString_NullReturnsEmpty() + { + var vo = new NullableStringVo(null); + Assert.Equal("", vo.ToString()); + } + + [Fact] + public void DateTimeValueObject_ToString_UsesInvariantCulture() + { + var de = CultureInfo.GetCultureInfo("de-DE"); + var prior = Thread.CurrentThread.CurrentCulture; + try + { + Thread.CurrentThread.CurrentCulture = de; + var when = new System.DateTime(2026, 5, 18, 10, 0, 0, System.DateTimeKind.Utc); + var vo = new DateTimeVo(when); + // The generator emits Value.ToString(CultureInfo.InvariantCulture) — assert that's + // independent of the thread culture set above. + Assert.Equal(when.ToString(CultureInfo.InvariantCulture), vo.ToString()); + } + finally + { + Thread.CurrentThread.CurrentCulture = prior; + } + } + + [Fact] + public void IntValueObject_GetHashCode_EqualsValueGetHashCode() + { + var id = new IntVo(42); + Assert.Equal(42.GetHashCode(), id.GetHashCode()); + } + + [Fact] + public void NullableStringValueObject_GetHashCode_NullReturnsZero() + { + var vo = new NullableStringVo(null); + Assert.Equal(0, vo.GetHashCode()); + } + + [Fact] + public void MultiProperty_ToString_KeepsWrappedFormat() + { + var vo = new GreetingVo { Name = "Marcel", Times = 3 }; + // Regression guard: multi-property [ValueObject] still emits the record-style wrapped form. + Assert.Equal("GreetingVo { Name = Marcel, Times = 3 }", vo.ToString()); + } +} From 1eca7c12f9832aaa6b9ec7b13cefa339e38286a3 Mon Sep 17 00:00:00 2001 From: Marcel Roozerkans Date: Mon, 18 May 2026 10:08:44 +0200 Subject: [PATCH 7/8] docs(perf): refresh Vogen comparison numbers after single-property emit fix --- docs/performance.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/performance.md b/docs/performance.md index 5b7e446..1d51f0f 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -37,21 +37,21 @@ ZA.ValueObjects matches `record` / `record struct` exactly. CFE allocates ~90 B ### Single-int wrapped IDs vs Vogen -_Last refreshed: 2026-05-13_ +_Last refreshed: 2026-05-18_ | Operation | Vogen | ZA.ValueObjects | Winner | |---|---:|---:|---| -| `From(value)` | 4.12 ns | **0.30 ns** | **ZA 14× faster** | -| `Equals` (equal) | 0.54 ns | **0.08 ns** | **ZA 7× faster** | -| `Equals` (not equal) | 0.67 ns | **0.20 ns** | **ZA 3× faster** | -| `GetHashCode` | **0.05 ns** | 1.50 ns | Vogen 30× faster | -| `ToString` | **4.45 ns** | 41.75 ns / **72 B** | Vogen 9× faster; ZA allocates | +| `From(value)` | 4.66 ns | **0.39 ns** | **ZA 12× faster** | +| `Equals` (equal) | 1.15 ns | **0.09 ns** | **ZA 13× faster** | +| `Equals` (not equal) | 0.31 ns | **0.02 ns** | **ZA 15× faster** | +| `GetHashCode` | 0.03 ns | 0.42 ns | parity (both in BDN ZeroMeasurement zone) | +| `ToString` | 6.40 ns | **3.52 ns** | **ZA 1.8× faster** | -Both libraries are 0 B on equality and construction. ZA wins the hot-path operations (`From`, `Equals`) by a wide margin — Vogen's `From` pays validation overhead even when the validation succeeds. Vogen wins `GetHashCode` (its primitive-wrapped hash inlines to the raw int) and `ToString` (no allocation; ZA's default `ToString` boxes through string formatting and allocates 72 B). +Both libraries are 0 B on every row. ZA wins the hot-path operations (`From`, `Equals`) by a wide margin — Vogen's `From` pays validation overhead even when validation succeeds. `GetHashCode` is effectively a tie (both rows sit in BDN's ZeroMeasurement zone — "indistinguishable from empty method"). `ToString` is now ZA's win after the single-property generator emit was aligned: the generator emits `Value.ToString(CultureInfo.InvariantCulture)` directly instead of the previous record-wrapped `$"TypeName {{ Value = {Value} }}"` interpolation. -**The trade-off**: ZA optimises construction and equality; Vogen optimises hashing and formatting. For value-object usage dominated by lookup-key equality (dictionary keys, set membership, change tracking), ZA wins. For value-object usage dominated by logging and display, Vogen wins. +**The trade-off**: ZA optimises construction, equality, and now `ToString` and hashing alongside Vogen. Vogen's narrower wrapping (single primitive) and ZA's broader surface (multi-field, custom types, EF Core converters) make them complementary choices — pick by feature surface, not raw single-int benchmark numbers. -ZA's `ToString` allocation is a known cost; it can be eliminated by overriding `ToString()` manually with a direct `value.ToString(CultureInfo.InvariantCulture)`. +History: the previous single-property `ToString` allocated ~72 B per call and `GetHashCode` was ~30× slower than Vogen, both fixed in ZeroAlloc.ValueObjects v1.7 by emitting bare `Value.ToString(InvariantCulture)` / `Value.GetHashCode()` for 1-property `[ValueObject]` types. Multi-property types are unchanged. ### Multi-field is a ZA-only feature From ff08ab07fd112b7f682db72e334e8ea19b1e0ea9 Mon Sep 17 00:00:00 2001 From: Marcel Roozerkans Date: Mon, 18 May 2026 10:09:14 +0200 Subject: [PATCH 8/8] docs(readme): refresh Vogen comparison row after single-property emit fix --- README.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 2de9ce7..23a74fe 100644 --- a/README.md +++ b/README.md @@ -54,13 +54,13 @@ string s = a.ToString(); // "Money { Amount = 10, Currency = USD }" | Operation | Vogen | ZA.ValueObjects | Winner | |---|---:|---:|---| -| `From(value)` | 4.12 ns | **0.30 ns** | **ZA 14× faster** | -| `Equals` (equal) | 0.54 ns | **0.08 ns** | **ZA 7× faster** | -| `Equals` (not equal) | 0.67 ns | **0.20 ns** | **ZA 3× faster** | -| `GetHashCode` | **0.05 ns** | 1.50 ns | Vogen 30× faster | -| `ToString` | **4.45 ns** | 41.75 ns / 72 B | Vogen 9× faster | +| `From(value)` | 4.66 ns | **0.39 ns** | **ZA 12× faster** | +| `Equals` (equal) | 1.15 ns | **0.09 ns** | **ZA 13× faster** | +| `Equals` (not equal) | 0.31 ns | **0.02 ns** | **ZA 15× faster** | +| `GetHashCode` | 0.03 ns | 0.42 ns | parity (both in ZeroMeasurement zone) | +| `ToString` | 6.40 ns | **3.52 ns** | **ZA 1.8× faster** | -ZA wins the hot-path operations users hit most (construction + equality). Vogen wins formatting and hashing. **ZA also supports multi-field types** — `[ValueObject]` on records with any number of fields — which Vogen does not. +ZA wins or ties every row, with 0 B allocated on all of them. The previous regressions on `GetHashCode` (~30× slower) and `ToString` (~72 B allocation) were closed in v1.7 by aligning the single-property emit to `Value.ToString(InvariantCulture)` / `Value.GetHashCode()` directly. **ZA also supports multi-field types** — `[ValueObject]` on records with any number of fields — which Vogen does not. Full methodology and analysis: [docs/performance.md](https://github.com/ZeroAlloc-Net/ZeroAlloc.ValueObjects/blob/main/docs/performance.md)