65.9K
CodeProject 正在变化。 阅读更多。
Home

C#11 - 不可变对象模式

starIconstarIconstarIconstarIconstarIcon

5.00/5 (2投票s)

2023年2月7日

CPOL

6分钟阅读

viewsIcon

11826

downloadIcon

74

不可变对象模式的入门教程,附带 C# 示例

1. 不可变对象定义

C# 中的不可变对象 (内部不可变性) 是指在创建后其内部状态不能更改的对象。这与普通对象 (可变对象) 不同,普通对象的内部状态通常在创建后可以更改。C# 对象的不可变性在编译时强制执行。不可变性是一种编译时约束,它表明程序员可以通过对象的正常接口进行哪些操作。

这里有一个小小的混淆,因为有时在不可变对象下,会假设以下定义

C# 中的不可变对象 (可观察不可变性) ([2]) 是指在创建后其公共状态不能更改的对象。在这种情况下,我们不关心对象的内部状态是否随时间变化,只要公共的、可观察的状态始终相同。对于代码的其余部分,它总是显示为同一个对象,因为它是这样被看到的。

2. 查找对象地址的实用工具

由于我们将在示例中展示堆栈和堆上的对象,为了更好地展示行为差异,我们开发了一个小工具,它将给出相关对象的地址,因此通过比较地址,可以很容易地看出我们谈论的是相同还是不同的对象。唯一的问题是,我们的地址查找工具有一个限制,即它只适用于堆上不包含其他堆上对象(引用)的对象。因此,我们被迫在对象中只使用原始值,这就是我需要避免使用 C# string 而只使用 char 类型的原因。

这是那个地址查找工具。我们创建了两个,一个用于基于 class 的对象,另一个用于基于 struct 的对象。问题是我们想避免基于 struct 的对象的装箱,因为那会给我们装箱对象在堆上的地址,而不是原始对象在堆栈上的地址。我们使用泛型来阻止不正确地使用这些工具。

public static Tuple<string?, string?>
    GetMemoryAddressOfClass<T1, T2>(T1 o1, T2 o2)
    where T1 : class
    where T2 : class
{
    //using generics to block structs, that would be boxed
    //so we would get address of a boxed object, not struct
    //works only for objects that do not contain references
    // to other objects
    string? address1 = null;
    string? address2 = null;

    GCHandle? handleO1 = null;
    GCHandle? handleO2 = null;

    if (o1 != null)
    {
        handleO1 = GCHandle.Alloc(o1, GCHandleType.Pinned);
    }

    if (o2 != null)
    {
        handleO2 = GCHandle.Alloc(o2, GCHandleType.Pinned);
    }

    if (handleO1 != null)
    {
        IntPtr pointer1 = handleO1.Value.AddrOfPinnedObject();
        address1 = "0x" + pointer1.ToString("X");
    }

    if (handleO2 != null)
    {
        IntPtr pointer2 = handleO2.Value.AddrOfPinnedObject();
        address2 = "0x" + pointer2.ToString("X");
    }

    if (handleO1 != null)
    {
        handleO1.Value.Free();
    }

    if (handleO2 != null)
    {
        handleO2.Value.Free();
    }

    Tuple<string?, string?> result = 
        new Tuple<string?, string?>(address1, address2);

    return result;
}

public static unsafe string? 
    GetMemoryAddressOfStruct<T1>(ref T1 o1)
    where T1 : unmanaged
{
    //In order to satisfy this constraint "unmanaged" a type must be a struct
    //and all the fields of the type must be unmanaged
    //using ref, so I would not get a value copy
    string? result = null;
    fixed (void* pointer1 = (&o1))
    {
        result = $"0x{(long)pointer1:X}";
    }

    return result;
}

3. 可变对象(基于类的)示例

这是一个可变对象(基于类的)的示例,这意味着它在托管堆上。这是一个示例执行和变异。然后是执行结果

public class CarClass
{
    public CarClass(Char? brand, Char? model, int? year)
    {
        Brand = brand;
        Model = model;
        Year = year;
    }

    public Char? Brand { get; set; }
    public Char? Model { get; set; }
    public int? Year { get; set; }
    public override string ToString()
    {
        return $"Brand:{Brand}, Model:{Model}, Year:{Year}";
    }
}    
//============================================
//===Sample code==============================
//class based objects
Console.WriteLine("-----");
Console.WriteLine("Mutation of mutable class object");
CarClass car1 = new CarClass('T', 'C', 2022);
Console.WriteLine($"Before mutation: car1={car1}");
car1.Model = 'A';
Console.WriteLine($"After  mutation: car1={car1}");
Console.WriteLine();

//--assigning class based objects
Console.WriteLine("-----");
Console.WriteLine("Assignment of mutable class object");
Console.WriteLine("From addresses you can see that assignment created ");
Console.WriteLine("two references pointing to the same object on heap ");
CarClass car3 = new CarClass('T', 'C', 1991);
CarClass car4 = car3;

Tuple<string?, string?> addresses1 = Util.GetMemoryAddressOfClass(car3, car4);
Console.WriteLine($"Address car3={addresses1.Item1}, Address car4={addresses1.Item2}");

Console.WriteLine($"Before mutation: car3={car3}");
Console.WriteLine($"Before mutation: car4={car4}");
car4.Model = 'Y';
Console.WriteLine($"After  mutation: car3={car3}");
Console.WriteLine($"After  mutation: car4={car4}");
Console.WriteLine();
//============================================
//===Result of execution======================
/*
-----
Mutation of mutable class object
Before mutation: car1=Brand:T, Model:C, Year:2022
After  mutation: car1=Brand:T, Model:A, Year:2022

-----
Assignment of mutable class object
From addresses you can see that assignment created
two references pointing to the same object on heap
Address car3=0x21E4F160280, Address car4=0x21E4F160280
Before mutation: car3=Brand:T, Model:C, Year:1991
Before mutation: car4=Brand:T, Model:C, Year:1991
After  mutation: car3=Brand:T, Model:Y, Year:1991
After  mutation: car4=Brand:T, Model:Y, Year:1991
*/

众所周知,类类型具有引用语义 ([3]),赋值只是引用的赋值,指向同一个对象。因此,赋值只是复制了一个引用,我们有这样一种情况:两个引用指向堆上的一个对象,无论我们使用哪个引用,该对象都被修改了。

4. 可变对象(基于结构的)示例

这是一个可变对象(基于 struct 的)的示例,这意味着它在堆栈上。这是一个示例执行和变异。然后是执行结果

public struct CarStruct
{
    public CarStruct(Char? brand, Char? model, int? year)
    {
        Brand = brand;
        Model = model;
        Year = year;
    }

    public Char? Brand { get; set; }
    public Char? Model { get; set; }
    public int? Year { get; set; }
    public override string ToString()
    {
        return $"Brand:{Brand}, Model:{Model}, Year:{Year}";
    }
}

//=============================================
//===Sample code===============================
//struct based objects
Console.WriteLine("-----");
Console.WriteLine("Mutation of mutable struct object");
CarStruct car5 = new CarStruct('T', 'C', 2022);
Console.WriteLine($"Before mutation: car5={car5}");
car5.Model = 'Y';
Console.WriteLine($"After  mutation: car5={car5}");
Console.WriteLine();

//--assigning struct based objects
Console.WriteLine("-----");
Console.WriteLine("Assignment of mutable struct object");
Console.WriteLine("From addresses you can see that assignment created ");
Console.WriteLine("two different object on the stack ");
CarStruct car7 = new CarStruct('T', 'C', 1991);
CarStruct car8 = car7;

string? address7 = Util.GetMemoryAddressOfStruct(ref car7);
string? address8 = Util.GetMemoryAddressOfStruct(ref car8);
Console.WriteLine($"Address car7={address7}, Address car8={address8}");

Console.WriteLine($"Before mutation: car7={car7}");
Console.WriteLine($"Before mutation: car8={car8}");
car8.Model = 'M';
Console.WriteLine($"After  mutation: car7={car7}");
Console.WriteLine($"After  mutation: car8={car8}");
Console.WriteLine();
//=============================================
//===Result of execution=======================
/*
Mutation of mutable struct object
Before mutation: car5=Brand:T, Model:C, Year:2022
After  mutation: car5=Brand:T, Model:Y, Year:2022

-----
Assignment of mutable struct object
From addresses you can see that assignment created
two different object on the stack
Address car7=0x2A7F79E570, Address car8=0x2A7F79E560
Before mutation: car7=Brand:T, Model:C, Year:1991
Before mutation: car8=Brand:T, Model:C, Year:1991
After  mutation: car7=Brand:T, Model:C, Year:1991
After  mutation: car8=Brand:T, Model:M, Year:1991
*/

众所周知,struct 具有值语义 ([3]),在赋值时,会复制该类型的一个实例。这与上面所示的基于类的对象(引用类型)的行为不同。正如我们所看到的,赋值创建了一个新的对象实例,因此变异只影响了新的实例。

5. 不可变对象(基于结构的)示例

5.1. 方法 1 – 只读属性

您可以通过使用 readonly 关键字标记所有 public 属性来创建一个基于 struct 类型的不可变对象。此类属性只能在对象的构造阶段进行更改,之后是不可变的。在这种情况下,在对象的初始化阶段设置属性是不可能的。

public struct CarStructI1
{
    public CarStructI1(Char? brand, Char? model, int? year)
    {
        Brand = brand;
        Model = model;
        Year = year;
    }

    public readonly Char? Brand { get;  }
    public readonly Char? Model { get;  }
    public readonly int? Year { get;  }
    public override readonly string ToString()
    {
        return $"Brand:{Brand}, Model:{Model}, Year:{Year}";
    }
}
    
//------------------------------------
CarStructI1 car10 = new CarStructI1('T', 'C', 2022);
//next line will not compile, since is readonly property
//car10.Model = 'Y';
//next line will not compile, can not initialize readonly property
//CarStructI1 car11 = new CarStructI1() { Brand = 'A', Model = 'A', Year = 2000 };    

5.2. 方法 2 – Init-Setter 属性

您可以通过为 setter 将所有 public 属性标记为 init 关键字来创建一个基于 struct 类型的不可变对象。此类属性只能在对象的构造阶段和对象的初始化阶段进行更改,之后是不可变的。在这种情况下,在对象的初始化阶段设置属性是可能的。

public struct CarStructI2
{
    public CarStructI2(Char? brand, Char? model, int? year)
    {
        Brand = brand;
        Model = model;
        Year = year;
    }

    public Char? Brand { get; init; }
    public Char? Model { get; init; }
    public int? Year { get; init; }
    public override readonly string ToString()
    {
        return $"Brand:{Brand}, Model:{Model}, Year:{Year}";
    }
}

//---------------------------------------
CarStructI2 car20 = new CarStructI2('T', 'C', 2022);
//next line will not compile, since is readonly property
//car20.Model = 'Y';
//this works now
CarStructI2 car21 = new CarStructI2() { Brand = 'A', Model = 'A', Year = 2000 };

5.3. 方法 3 – 只读结构体

您可以通过使用 readonly 关键字标记 struct 来创建一个基于 struct 类型的不可变对象。在这种 struct 中,所有属性都必须标记为 readonly,并且只能在对象的构造阶段进行更改,之后是不可变的。在这种情况下,在对象的初始化阶段设置属性是不可能的。在这种情况下,我看不出它与上述方法 1 有什么区别,除了在 struct 级别定义上很容易看出该 struct 的意图,即 struct 创建者从一开始就计划使其不可变。

public readonly struct CarStructI3
{
    public CarStructI3(Char? brand, Char? model, int? year)
    {
        Brand = brand;
        Model = model;
        Year = year;
    }

    public Char? Brand { get;  }
    public Char? Model { get;  }
    public int? Year { get;  }
    public override string ToString()
    {
        return $"Brand:{Brand}, Model:{Model}, Year:{Year}";
    }
}

//--------------------------------------
CarStructI3 car30= new CarStructI3('T', 'C', 2022);
//next line will not compile, since is readonly property
//car30.Model = 'Y';
//next line will not compile, can not initialize readonly property
//CarStructI3 car31 = new CarStructI1() { Brand = 'A', Model = 'A', Year = 2000 };

6. 不可变对象(基于类的)示例

6.1. 方法 1 – 只读属性

您可以通过移除 setter 使所有 public 属性为只读,从而创建一个基于类的 Immutable 对象。此类属性只能由类的 private 成员进行修改。在这种情况下,在对象的初始化阶段设置属性是不可能的。

 public class CarClassI1
{
    public CarClassI1(Char? brand, Char? model, int? year)
    {
        Brand = brand;
        Model = model;
        Year = year;
    }

    public CarClassI1()
    { }

    public Char? Brand { get;  }
    public Char? Model { get;  }
    public int? Year { get;  }
    public override string ToString()
    {
        return $"Brand:{Brand}, Model:{Model}, Year:{Year}";
    }
}

//----------------------------------
CarClassI1 car50 = new CarClassI1('T', 'C', 2022);
//next line will not compile, since is readonly property
//car50.Model = 'Y';
//next line will not compile, can not initialize readonly property
//CarClassI1 car51 = new CarClassI1() { Brand = 'A', Model = 'A', Year = 2000 };

6.2. 方法 2 – Init-Setter 属性

您可以通过为 setter 将所有 public 属性标记为 init 关键字来创建一个基于 class 类型的不可变对象。此类属性只能在对象的构造阶段和对象的初始化阶段进行更改,之后是不可变的。在这种情况下,在对象的初始化阶段设置属性是可能的。

public class CarClassI2
{
    public CarClassI2(Char? brand, Char? model, int? year)
    {
        Brand = brand;
        Model = model;
        Year = year;
    }

    public CarClassI2()
    { }

    public Char? Brand { get; init; }
    public Char? Model { get; init; }
    public int? Year { get; init; }
    public override string ToString()
    {
        return $"Brand:{Brand}, Model:{Model}, Year:{Year}";
    }
}

//------------------------------------------
CarClassI2 car60 = new CarClassI2('T', 'C', 2022);
//next line will not compile, since is readonly property
//car60.Model = 'Y';
//this works now
CarClassI2 car61 = new CarClassI2() { Brand = 'A', Model = 'A', Year = 2000 };

7. 内部不可变性与可观察不可变性

以上所有情况都是内部不可变性的不可变对象。让我们举一个可观察不可变性的不可变对象的例子。下面就是一个这样的例子。我们基本上缓存了长期价格计算的结果。对象总是报告相同的状态,因此它满足可观察不可变性,但其内部状态会发生变化,因此它不满足内部不可变性

public class CarClassI1
{
    public CarClassI1(Char? brand, Char? model, int? year)
    {
        Brand = brand;
        Model = model;
        Year = year;
    }
    public Char? Brand { get; }
    public Char? Model { get; }
    public int? Year { get; }
    public int? Price
    {
        get
        {
            // not thread safe
            if (_price== null)
            {
                LongPriceCalcualtion();
            }
            return _price;  
        }
    }

    private int? _price = null;

    private void LongPriceCalcualtion()
    {
        _price = 0;
        Thread.Sleep(1000); //long features calculation
        _price += 10_000;
        Thread.Sleep(1000); //long engine price calculation
        _price += 10_000;
        Thread.Sleep(1000); //long tax calculation
        _price += 10_000;
    }
    public override string ToString()
    {
        return $"Brand:{Brand}, Model:{Model}, Year:{Year}, Price:{Price}";
    }
}

//=============================================
//===Sample code===============================
CarClassI1 car50 = new CarClassI1('T', 'C', 2022);

Console.WriteLine($"The 1st object state: car50={car50}");
Console.WriteLine($"The 2nd object state: car50={car50}");

//=============================================
//===Result of execution=======================
/*
The 1st object state: car50=Brand:T, Model:C, Year:2022, Price:30000
The 2nd object state: car50=Brand:T, Model:C, Year:2022, Price:30000
*/

8. 线程安全与不可变性

内部不可变性的不可变对象是天生线程安全的。这源于简单的逻辑,即所有共享资源都是只读的,因此线程之间不可能相互干扰。

可观察不可变性的不可变对象不一定线程安全,上述示例就说明了这一点。获取状态会调用一些 private 的非线程安全方法,最终结果不是线程安全的。如果从两个不同的线程访问,上述对象可能会报告不同的状态。

9. 不可变对象(基于结构的)和非破坏性变异

如果您想重用一个不可变对象,您可以随意多次引用它,因为保证它不会改变。但是,如果您想重用不可变对象的某些数据,但稍微修改一下呢?这就是发明非破坏性变异的原因。在 C# 语言中,现在您可以使用 with 关键字来实现它。通常,您会希望保留不可变对象的大部分状态,但只更改某些属性。以下是 C#10 及以后版本中如何实现的方法。

public struct CarStructI2
{
    public CarStructI2(Char? brand, Char? model, int? year)
    {
        Brand = brand;
        Model = model;
        Year = year;
    }

    public Char? Brand { get; init; }
    public Char? Model { get; init; }
    public int? Year { get; init; }
    public override readonly string ToString()
    {
        return $"Brand:{Brand}, Model:{Model}, Year:{Year}";
    }
}

//=============================================
//===Sample code===============================
//struct based objects
Console.WriteLine("-----");
Console.WriteLine("Nondestructive Mutation of immutable struct object");
CarStructI2 car7 = new CarStructI2('T', 'C', 1991);
CarStructI2 car8 = car7 with { Brand = 'A' };

string? address1 = Util.GetMemoryAddressOfStruct(ref car7);
string? address2 = Util.GetMemoryAddressOfStruct(ref car8);
Console.WriteLine($"Address car7={address1}, Address car8={address2}");

Console.WriteLine($"State: car7={car7}");
Console.WriteLine($"State: car8={car8}");
Console.WriteLine();

//=============================================
//===Result of execution=======================
/*
-----
Nondestructive Mutation of immutable struct object
Address car7=0xC4A4FCE420, Address car8=0xC4A4FCE410
State: car7=Brand:T, Model:C, Year:1991
State: car8=Brand:A, Model:C, Year:1991
*/

10. 不可变对象(基于类的)和非破坏性变异

对于基于类的不可变对象,C# 语言并没有扩展新的 with 关键字,但仍然可以轻松地自定义编程实现相同的功能。这是一个例子

public class CarClassI4
{
    public CarClassI4(Char? brand, Char? model, int? year)
    {
        Brand = brand;
        Model = model;
        Year = year;
    }

    public Char? Brand { get;  }
    public Char? Model { get;  }
    public int? Year { get;  }

    public CarClassI4 NondestructiveMutation 
        ( Char? Brand=null,  Char? Model = null, int? Year=null)
    {
        return new CarClassI4(
            Brand ?? this.Brand, Model ?? this.Model, Year ?? this.Year);
    }
    public override string ToString()
    {
        return $"Brand:{Brand}, Model:{Model}, Year:{Year}";
    }
}

//=============================================
//===Sample code===============================
//class based objects
Console.WriteLine("-----");
Console.WriteLine("Nondestructive Mutation of immutable class object");
CarClassI4 car1 = new CarClassI4('T', 'C', 1991);
CarClassI4 car2 = car1.NondestructiveMutation(Model:'M');


Tuple<string?, string?> addresses2 = Util.GetMemoryAddressOfClass(car1, car2);
Console.WriteLine($"Address car1={addresses2.Item1}, Address car2={addresses2.Item2}");

Console.WriteLine($"State: car1={car1}");
Console.WriteLine($"State: car2={car2}");
Console.WriteLine();
//=============================================
//===Result of execution=======================
/*
-----
Nondestructive Mutation of immutable class object
Address car1=0x238EED63FA8, Address car2=0x238EED63FC8
State: car1=Brand:T, Model:C, Year:1991
State: car2=Brand:T, Model:M, Year:1991
*/

11. 结论

不可变对象模式非常流行,并且经常使用。在这里,我们介绍了在 C# 中创建不可变 structclass 的方法,并给出了一些有趣的示例。

我们讨论了内部不可变性可观察不可变性,并讨论了线程安全问题。

推荐读者感兴趣的相关概念是值对象C# 中的记录

12. 参考文献

13. 历史

  • 2023 年 2 月 7 日:初始版本
© . All rights reserved.