// MIT License - Copyright (c) 2025 wallstop // Full license text: https://github.com/wallstop/unity-helpers/blob/main/LICENSE namespace WallstopStudios.UnityHelpers.Core.Extension { using System; using System.Collections.Generic; using System.Runtime.CompilerServices; using WallstopStudios.UnityHelpers.Core.Serialization; using WallstopStudios.UnityHelpers.Utils; using PbSerializer = ProtoBuf.Serializer; /// /// Provides extensions and equality comparers that use protobuf-net serialization output /// to compare values for equality, avoiding intermediate byte[] allocations. /// /// /// Thread Safety: Thread-safe for value types. Reference types are safe if not modified during comparison. /// Performance: Requires full serialization of both objects for comparison - can be expensive for large objects. /// Use for deep equality where standard equality is insufficient (e.g., comparing complex object graphs). /// public static class ProtoEqualityExtensions { /// /// Compares two instances for equality by serializing them via protobuf and comparing the resulting bytes. /// This implementation writes to reusable MemoryStreams and compares their backing buffers without ToArray(). /// /// The type of objects to compare (must be protobuf-serializable). /// The first object to compare. /// The second object to compare. /// True if both objects serialize to identical byte sequences, false otherwise. /// /// Null Handling: For reference types, null == null returns true, null vs non-null returns false. /// For value types, uses default equality for nullability. /// Thread Safety: Thread-safe if objects are not modified during comparison. Uses thread-safe pooled resources. /// Performance: O(n) where n is serialized size. Requires full serialization of both objects. /// Very expensive for large objects - consider caching or custom equality when possible. /// Allocations: Uses pooled PooledBufferStream instances - no heap allocations for buffers. /// Edge Cases: ReferenceEquals check short-circuits for identical reference types. Value types always serialize. /// Requires both types to be protobuf-serializable with consistent serialization. /// [MethodImpl(MethodImplOptions.AggressiveInlining)] public static bool ProtoEquals(this T self, T other) { if (!typeof(T).IsValueType) { if (ReferenceEquals(self, other)) { return true; } if (self is null || other is null) { return false; } } using PooledResource aLease = PooledBufferStream.Rent( out PooledBufferStream a ); using PooledResource bLease = PooledBufferStream.Rent( out PooledBufferStream b ); // Mirror Serializer's decision logic to prefer runtime type for interface/abstract/object Type declared = typeof(T); bool useRuntime = Serializer.ShouldUseRuntimeTypeForProtobuf( declared, self, forceRuntimeType: false ); if (useRuntime) { PbSerializer.NonGeneric.Serialize(a, self); PbSerializer.NonGeneric.Serialize(b, other); } else { PbSerializer.Serialize(a, self); PbSerializer.Serialize(b, other); } return ProtoBufferComparer.StreamContentEquals(a, b); } /// /// Returns a singleton equality comparer that compares values by their protobuf serialization. /// /// The type of objects to compare (must be protobuf-serializable). /// A cached singleton instance of ProtoEqualityComparer<T>. /// /// Null Handling: The returned comparer handles nulls as per ProtoEquals. /// Thread Safety: Thread-safe - returns a singleton instance. /// Performance: O(1) - returns cached singleton. /// Allocations: No allocations - uses pre-initialized singleton. /// Edge Cases: Same comparer instance is returned for each type T across all calls. /// public static IEqualityComparer GetProtoComparer() { return ProtoEqualityComparer.Instance; } } /// /// Generic proto-based equality comparer using protobuf serialization output, with minimal allocations. /// Implements IEqualityComparer<T> for use in dictionaries, hash sets, and LINQ operations. /// /// The type of objects to compare (must be protobuf-serializable). /// /// Thread Safety: Thread-safe singleton pattern. Comparison operations are thread-safe if objects are not modified. /// Performance: Expensive - requires full serialization for both Equals and GetHashCode operations. /// Allocations: Uses pooled PooledBufferStream instances - minimal heap allocations. /// public sealed class ProtoEqualityComparer : IEqualityComparer { /// /// Singleton instance of the comparer for type T. /// public static readonly ProtoEqualityComparer Instance = new(); private readonly bool _isValueType; private ProtoEqualityComparer() { _isValueType = typeof(T).IsValueType; } /// /// Determines whether two objects of type T are equal by comparing their protobuf serialization. /// /// The first object to compare. /// The second object to compare. /// True if both objects serialize to identical byte sequences, false otherwise. /// /// Null Handling: For reference types, null == null returns true, null vs non-null returns false. /// Thread Safety: Thread-safe if objects are not modified during comparison. /// Performance: O(n) where n is serialized size - requires full serialization of both objects. /// Allocations: Uses pooled buffers - no heap allocations for streams. /// Edge Cases: ReferenceEquals short-circuits for identical references. /// public bool Equals(T x, T y) { if (!_isValueType) { if (ReferenceEquals(x, y)) { return true; } if (x is null || y is null) { return false; } } using PooledResource aLease = PooledBufferStream.Rent( out PooledBufferStream a ); using PooledResource bLease = PooledBufferStream.Rent( out PooledBufferStream b ); Type declared = typeof(T); bool useRuntime = Serializer.ShouldUseRuntimeTypeForProtobuf( declared, x, forceRuntimeType: false ); if (useRuntime) { PbSerializer.NonGeneric.Serialize(a, x); PbSerializer.NonGeneric.Serialize(b, y); } else { PbSerializer.Serialize(a, x); PbSerializer.Serialize(b, y); } return ProtoBufferComparer.StreamContentEquals(a, b); } /// /// Returns a hash code for the object based on its protobuf serialization using FNV-1a algorithm. /// /// The object to compute a hash code for. /// A 32-bit hash code computed from the object's serialized bytes using FNV-1a. /// /// Null Handling: Behavior for null depends on protobuf serialization handling. /// Thread Safety: Thread-safe if obj is not modified during hashing. /// Performance: O(n) where n is serialized size - requires full serialization. /// Allocations: Uses pooled buffer - no heap allocations for stream. /// Edge Cases: Hash collisions possible but minimized by FNV-1a algorithm. /// Objects that serialize to same bytes will have same hash code (required for equality contract). /// public int GetHashCode(T obj) { using PooledResource sLease = PooledBufferStream.Rent( out PooledBufferStream s ); Type declared = typeof(T); bool useRuntime = Serializer.ShouldUseRuntimeTypeForProtobuf( declared, obj, forceRuntimeType: false ); if (useRuntime) { PbSerializer.NonGeneric.Serialize(s, obj); } else { PbSerializer.Serialize(s, obj); } return ProtoBufferComparer.Fnv1A32(s); } } /// /// Internal helper class for comparing protobuf-serialized stream contents and computing hash codes. /// internal static class ProtoBufferComparer { /// /// Compares the written content of two PooledBufferStream instances for byte-wise equality. /// /// The first stream to compare. /// The second stream to compare. /// True if both streams contain identical byte sequences, false otherwise. /// /// Null Handling: null == null returns true, null vs non-null returns false. /// Thread Safety: Thread-safe if streams are not modified during comparison. /// Performance: O(n) where n is byte count - uses optimized Span.SequenceEqual for SIMD acceleration. /// Allocations: No heap allocations - operates on ArraySegments and Spans. /// Edge Cases: ReferenceEquals short-circuits for same stream instance. /// Different-length streams return false immediately without byte comparison. /// [MethodImpl(MethodImplOptions.AggressiveInlining)] public static bool StreamContentEquals(PooledBufferStream a, PooledBufferStream b) { if (ReferenceEquals(a, b)) { return true; } if (a is null || b is null) { return false; } ArraySegment segA = a.GetWrittenSegment(); ArraySegment segB = b.GetWrittenSegment(); if (segA.Count != segB.Count) { return false; } // Use Span.SequenceEqual for efficient memory comparison return segA.AsSpan().SequenceEqual(segB.AsSpan()); } /// /// Computes a 32-bit FNV-1a hash code from the written content of a PooledBufferStream. /// /// The stream whose content to hash. /// A 32-bit FNV-1a hash code computed from the stream's byte content. /// /// Null Handling: Assumes stream is non-null (internal use only). /// Thread Safety: Thread-safe if stream is not modified during hashing. /// Performance: O(n) where n is byte count - iterates once through all bytes. /// Allocations: No heap allocations - operates on ArraySegment. /// Edge Cases: Uses FNV-1a algorithm with standard constants (offset=2166136261, prime=16777619). /// Empty streams produce the FNV offset basis hash. Unchecked arithmetic prevents overflow exceptions. /// [MethodImpl(MethodImplOptions.AggressiveInlining)] public static int Fnv1A32(PooledBufferStream s) { const uint fnvOffset = 2166136261; const uint fnvPrime = 16777619; uint hash = fnvOffset; ArraySegment seg = s.GetWrittenSegment(); foreach (byte element in seg) { hash ^= element; hash *= fnvPrime; } return unchecked((int)hash); } } }