diff --git a/Extensions.Standard.Test/ExtensionsTest.cs b/Extensions.Standard.Test/ExtensionsTest.cs index c496a76..e02c232 100644 --- a/Extensions.Standard.Test/ExtensionsTest.cs +++ b/Extensions.Standard.Test/ExtensionsTest.cs @@ -4,7 +4,6 @@ using System.Linq; using Xunit; using NSubstitute; -using Newtonsoft.Json; namespace Extensions.Standard.Test { @@ -1065,22 +1064,75 @@ public void DeepCopy_DefaultSettings_ReturnsDeepCopy() } [Fact] - public void DeepCopy_CustomSettings_HandlesCustomSerialization() + public void DeepCopy_NestedReferenceAndCollection_AreClonedNotShared() { - // Arrange - var original = new TestClass { Id = 1, Name = "Test" }; - var settings = new JsonSerializerSettings + var original = new Container { - NullValueHandling = NullValueHandling.Ignore + A = new TestClass { Id = 1, Name = "x" }, + Numbers = new List { 1, 2, 3 } }; - // Act - var copy = original.DeepCopy(settings); + var copy = original.DeepCopy(); + + Assert.NotSame(original.A, copy.A); + Assert.Equal("x", copy.A.Name); + Assert.NotSame(original.Numbers, copy.Numbers); + + copy.Numbers.Add(4); + copy.A.Name = "changed"; + Assert.Equal(3, original.Numbers.Count); // original list untouched + Assert.Equal("x", original.A.Name); // original nested object untouched + } + + [Fact] + public void DeepCopy_CopiesPrivateFields() + { + var original = new WithPrivateField(42); + + var copy = original.DeepCopy(); - // Assert Assert.NotSame(original, copy); - Assert.Equal(original.Id, copy.Id); - Assert.Equal(original.Name, copy.Name); + Assert.Equal(42, copy.Secret); + } + + [Fact] + public void DeepCopy_PreservesSharedReferenceIdentity() + { + var shared = new TestClass { Id = 7, Name = "shared" }; + var original = new Container { A = shared, B = shared }; + + var copy = original.DeepCopy(); + + Assert.NotSame(shared, copy.A); + Assert.Same(copy.A, copy.B); // one object shared by two fields stays a single object + } + + [Fact] + public void DeepCopy_HandlesReferenceCycles() + { + var a = new Node { Value = 1 }; + var b = new Node { Value = 2 }; + a.Next = b; + b.Next = a; // cycle + + var copy = a.DeepCopy(); + + Assert.NotSame(a, copy); + Assert.Equal(1, copy.Value); + Assert.Equal(2, copy.Next.Value); + Assert.Same(copy, copy.Next.Next); // cycle preserved, not infinitely expanded + } + + [Fact] + public void DeepCopy_Array_IsDeepCloned() + { + var original = new[] { new TestClass { Id = 1, Name = "a" }, new TestClass { Id = 2, Name = "b" } }; + + var copy = original.DeepCopy(); + + Assert.NotSame(original, copy); + Assert.NotSame(original[0], copy[0]); + Assert.Equal("b", copy[1].Name); } [Fact] @@ -1096,6 +1148,169 @@ public void DeepCopy_NullInput_ReturnsNull() Assert.Null(copy); } + [Fact] + public void DeepCopy_ValueTypesAndImmutables_ReturnEqualValues() + { + Assert.Equal(42, 42.DeepCopy()); + Assert.Equal("hello", "hello".DeepCopy()); + Assert.Equal(3.14m, 3.14m.DeepCopy()); + Assert.Equal(DayOfWeek.Monday, DayOfWeek.Monday.DeepCopy()); + var dt = new DateTime(2026, 6, 13); + Assert.Equal(dt, dt.DeepCopy()); + var g = Guid.NewGuid(); + Assert.Equal(g, g.DeepCopy()); + } + + [Fact] + public void DeepCopy_Nullable_PreservesValueAndNull() + { + int? hasValue = 5; + int? noValue = null; + Assert.Equal(5, hasValue.DeepCopy()); + Assert.Null(noValue.DeepCopy()); + } + + [Fact] + public void DeepCopy_CopiesInheritedPrivateFields() + { + var original = new DerivedWithValue(7, 9); + + var copy = original.DeepCopy(); + + Assert.NotSame(original, copy); + Assert.Equal(7, copy.BaseSecret); + Assert.Equal(9, copy.Derived); + } + + [Fact] + public void DeepCopy_StructWithReferenceField_IsDeepCloned() + { + var holder = new Holder { Inner = new TestClass { Id = 1, Name = "x" } }; + + var copy = holder.DeepCopy(); + + Assert.NotSame(holder.Inner, copy.Inner); + copy.Inner.Name = "y"; + Assert.Equal("x", holder.Inner.Name); + } + + [Fact] + public void DeepCopy_Delegate_IsCopiedByReference() + { + Action callback = () => { }; + var original = new WithDelegate { Value = 5, Callback = callback }; + + var copy = original.DeepCopy(); + + Assert.NotSame(original, copy); + Assert.Equal(5, copy.Value); + Assert.Same(callback, copy.Callback); + } + + [Fact] + public void DeepCopy_PrimitiveArray_IsIndependentCopy() + { + var original = new[] { 1, 2, 3 }; + + var copy = original.DeepCopy(); + + Assert.NotSame(original, copy); + Assert.Equal(original, copy); + copy[0] = 99; + Assert.Equal(1, original[0]); + } + + [Fact] + public void DeepCopy_EmptyArrayAndList_AreClonedEmpty() + { + var arr = new int[0]; + var arrCopy = arr.DeepCopy(); + Assert.NotSame(arr, arrCopy); + Assert.Empty(arrCopy); + + var list = new List(); + var listCopy = list.DeepCopy(); + Assert.NotSame(list, listCopy); + Assert.Empty(listCopy); + } + + [Fact] + public void DeepCopy_TwoDimensionalPrimitiveArray_IsIndependentCopy() + { + var grid = new[,] { { 1, 2 }, { 3, 4 } }; + + var copy = grid.DeepCopy(); + + Assert.NotSame(grid, copy); + Assert.Equal(2, copy.GetLength(0)); + Assert.Equal(2, copy.GetLength(1)); + Assert.Equal(4, copy[1, 1]); + copy[0, 0] = 99; + Assert.Equal(1, grid[0, 0]); + } + + [Fact] + public void DeepCopy_TwoDimensionalReferenceArray_DeepClonesElements() + { + var grid = new[,] { { new TestClass { Name = "a" }, new TestClass { Name = "b" } } }; + + var copy = grid.DeepCopy(); + + Assert.NotSame(grid[0, 0], copy[0, 0]); + Assert.Equal("a", copy[0, 0].Name); + Assert.Equal("b", copy[0, 1].Name); + } + + [Fact] + public void DeepCopy_JaggedArray_IsDeepCloned() + { + var jagged = new[] { new[] { 1, 2 }, new[] { 3 } }; + + var copy = jagged.DeepCopy(); + + Assert.NotSame(jagged[0], copy[0]); + copy[0][0] = 99; + Assert.Equal(1, jagged[0][0]); + } + + [Fact] + public void DeepCopy_Dictionary_IsDeepCloned() + { + var original = new Dictionary { ["a"] = new TestClass { Id = 1, Name = "x" } }; + + var copy = original.DeepCopy(); + + Assert.NotSame(original, copy); + Assert.NotSame(original["a"], copy["a"]); + Assert.Equal("x", copy["a"].Name); + copy["a"].Name = "y"; + Assert.Equal("x", original["a"].Name); + } + + [Fact] + public void DeepCopy_SelfReferencingObject_PreservesSelfCycle() + { + var node = new Node { Value = 1 }; + node.Next = node; + + var copy = node.DeepCopy(); + + Assert.NotSame(node, copy); + Assert.Same(copy, copy.Next); + } + + [Fact] + public void DeepCopy_SharedReferenceWithinCollection_StaysShared() + { + var shared = new TestClass { Name = "s" }; + var list = new List { shared, shared }; + + var copy = list.DeepCopy(); + + Assert.NotSame(shared, copy[0]); + Assert.Same(copy[0], copy[1]); + } + [Fact] public void IsEquivalentDetectsMismatchNotOnlyInLastElement() { @@ -1207,6 +1422,50 @@ private class TestClass public string Name { get; set; } } + private class Container + { + public TestClass A { get; set; } + public TestClass B { get; set; } + public List Numbers { get; set; } + } + + private class WithPrivateField + { + private readonly int _secret; + public WithPrivateField(int secret) => _secret = secret; + public int Secret => _secret; + } + + private class Node + { + public int Value { get; set; } + public Node Next { get; set; } + } + + private struct Holder + { + public TestClass Inner { get; set; } + } + + private class BaseWithPrivate + { + private readonly int _baseSecret; + public BaseWithPrivate(int baseSecret) => _baseSecret = baseSecret; + public int BaseSecret => _baseSecret; + } + + private class DerivedWithValue : BaseWithPrivate + { + public DerivedWithValue(int baseSecret, int derived) : base(baseSecret) => Derived = derived; + public int Derived { get; set; } + } + + private class WithDelegate + { + public int Value { get; set; } + public Action Callback { get; set; } + } + private static IList SoftmaxNaive(IList input) { var sum = input.Sum(t => Math.Exp(t)); diff --git a/Extensions.Standard/Extensions.Standard.csproj b/Extensions.Standard/Extensions.Standard.csproj index f0ae76d..09af85d 100644 --- a/Extensions.Standard/Extensions.Standard.csproj +++ b/Extensions.Standard/Extensions.Standard.csproj @@ -18,8 +18,8 @@ Suffixes (AsMemory, AsTime), Interpolate, Partition, Shuffle (O(n)), Softmax, In git extensions, extension-methods, netcore, netstandard, boilerplate README.md - 10.0.0 - Multi-target netstandard2.0 and net8.0. Fixed IsEquivalent, AsMemory/AsMemoryDecimal high orders, generic Scale, and AsColor packing. Deprecated InClosedRange/InOpenRange (misleading names) in favor of InRangeInclusive/InRangeExclusive, and ConstructLine in favor of ConstructLineFromRadians/ConstructLineFromDegrees. + 11.0.0 + DeepCopy is now a dependency-free reflection-based deep clone (copies private/inherited fields, preserves shared references and cycles, no serializable/parameterless-ctor requirement). Removed the Newtonsoft.Json dependency and the JsonSerializerSettings overload. MIT @@ -28,7 +28,6 @@ Suffixes (AsMemory, AsTime), Interpolate, Partition, Shuffle (O(n)), Softmax, In - diff --git a/Extensions.Standard/Utilities.cs b/Extensions.Standard/Utilities.cs index 1c57ca4..fede4dd 100644 --- a/Extensions.Standard/Utilities.cs +++ b/Extensions.Standard/Utilities.cs @@ -4,7 +4,6 @@ using System.Linq; using System.Reflection; using System.Text; -using Newtonsoft.Json; namespace Extensions.Standard { @@ -862,11 +861,147 @@ public static Dictionary GetAllPublicPropertiesValues(this T return properties; } - public static T DeepCopy(this T original, JsonSerializerSettings settings = null) + /// + /// Creates a deep copy of by recursively cloning every instance field + /// (including private and inherited ones). Shared references and reference cycles are preserved, and + /// types do not need to be serializable or expose a parameterless constructor. + /// + /// + /// Strings and other immutable framework types are reused as-is. Delegates and + /// instances are copied by reference (deep-cloning them is meaningless). Pointer fields are skipped. + /// + public static T DeepCopy(this T original) + { + if (original is null) return default; + return (T)DeepCopyObject(original, new Dictionary(ReferenceComparer.Instance)); + } + + private static readonly System.Collections.Concurrent.ConcurrentDictionary _fieldCache = + new System.Collections.Concurrent.ConcurrentDictionary(); + + private static object DeepCopyObject(object original, Dictionary visited) + { + if (original is null) return null; + + var type = original.GetType(); + + if (IsCopiedByValue(type) || original is Type || original is Delegate) + { + return original; + } + + if (visited.TryGetValue(original, out var alreadyCloned)) + { + return alreadyCloned; + } + + if (type.IsArray) + { + return DeepCopyArray((Array)original, type, visited); + } + + var clone = GetUninitializedObject(type); + visited[original] = clone; + + for (var current = type; current != null && current != typeof(object); current = current.BaseType) + { + foreach (var field in GetCopyableFields(current)) + { + var value = field.GetValue(original); + field.SetValue(clone, DeepCopyObject(value, visited)); + } + } + + return clone; + } + + private static object DeepCopyArray(Array source, Type arrayType, Dictionary visited) + { + var elementType = arrayType.GetElementType(); + var lengths = new int[source.Rank]; + var lowerBounds = new int[source.Rank]; + for (var d = 0; d < source.Rank; ++d) + { + lengths[d] = source.GetLength(d); + lowerBounds[d] = source.GetLowerBound(d); + } + + var clone = Array.CreateInstance(elementType, lengths, lowerBounds); + visited[source] = clone; + + if (IsCopiedByValue(elementType)) + { + Array.Copy(source, clone, source.Length); + } + else + { + foreach (var indices in EnumerateIndices(source)) + { + clone.SetValue(DeepCopyObject(source.GetValue(indices), visited), indices); + } + } + + return clone; + } + + private static IEnumerable EnumerateIndices(Array array) + { + if (array.Length == 0) yield break; + + var rank = array.Rank; + var indices = new int[rank]; + for (var d = 0; d < rank; ++d) indices[d] = array.GetLowerBound(d); + + while (true) + { + yield return (int[])indices.Clone(); + + var dim = rank - 1; + while (dim >= 0) + { + if (++indices[dim] <= array.GetUpperBound(dim)) break; + indices[dim] = array.GetLowerBound(dim); + --dim; + } + if (dim < 0) yield break; + } + } + + private static FieldInfo[] GetCopyableFields(Type type) + { + return _fieldCache.GetOrAdd(type, t => t + .GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.DeclaredOnly) + .Where(f => !f.FieldType.IsPointer) + .ToArray()); + } + + private static bool IsCopiedByValue(Type type) + { + if (type.IsPrimitive || type.IsEnum) return true; + return type == typeof(string) + || type == typeof(decimal) + || type == typeof(DateTime) + || type == typeof(DateTimeOffset) + || type == typeof(TimeSpan) + || type == typeof(Guid) + || type == typeof(IntPtr) + || type == typeof(UIntPtr); + } + + private static object GetUninitializedObject(Type type) + { +#if NET8_0_OR_GREATER + return System.Runtime.CompilerServices.RuntimeHelpers.GetUninitializedObject(type); +#else + return System.Runtime.Serialization.FormatterServices.GetUninitializedObject(type); +#endif + } + + private sealed class ReferenceComparer : IEqualityComparer { - return settings == null - ? JsonConvert.DeserializeObject(JsonConvert.SerializeObject(original)) - : JsonConvert.DeserializeObject(JsonConvert.SerializeObject(original, settings), settings); + public static readonly ReferenceComparer Instance = new ReferenceComparer(); + bool IEqualityComparer.Equals(object x, object y) => ReferenceEquals(x, y); + public int GetHashCode(object obj) => System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(obj); } #endregion } diff --git a/README.md b/README.md index e1502fe..b8d6585 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ Lightweight extensions methods (and constants) for common programming tasks, lik - HsVtoArgb - Scale - Fit +- DeepCopy (reflection-based deep clone, no dependencies) and many more.