Skip to content
Merged
12 changes: 6 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
18 changes: 9 additions & 9 deletions docs/performance.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,21 +37,21 @@ ZA.ValueObjects matches `record` / `record struct` exactly. CFE allocates ~90 B
### Single-int wrapped IDs vs Vogen

<!-- BENCH:START -->
_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.
<!-- BENCH:END -->

### Multi-field is a ZA-only feature
Expand Down
61 changes: 61 additions & 0 deletions src/ZeroAlloc.ValueObjects.Generator/Writers/SourceWriter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,48 @@ namespace ZeroAlloc.ValueObjects.Generator.Writers;

internal static class SourceWriter
{
private static readonly System.Collections.Generic.HashSet<string> 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);

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();
Expand Down Expand Up @@ -79,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<T>), .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));
Expand Down Expand Up @@ -121,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 + " }}\";");
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Loading