Шаблон неизменяемого объекта в C# 11

  • Михаил
  • 25 мин. на прочтение
  • 72
  • 23 Feb 2023
  • 01 Mar 2023

Неизменяемый объект (внутренняя неизменность) в C# — это объект, внутреннее состояние которого нельзя изменить после его создания. Это отличается от обычного объекта ( Mutable Object ), внутреннее состояние которого обычно можно изменить после создания. Неизменяемость объекта C# обеспечивается во время компиляции. Неизменяемость — это ограничение времени компиляции , которое сигнализирует о том, что программист может делать через обычный интерфейс объекта.

Существует небольшая путаница, поскольку иногда в Immutable Object предполагается следующее определение:

Неизменяемый объект (наблюдательная неизменность) в C# — это объект, публичное состояние которого нельзя изменить после его создания. В этом случае нас не волнует, меняется ли внутреннее состояние объекта с течением времени, если общедоступное, наблюдаемое состояние всегда остается одним и тем же. Для остального кода он всегда выглядит как один и тот же объект, потому что именно так его и видят.

1. Утилита для поиска Адресов объектов

Поскольку в наших примерах мы собираемся показывать объекты как в стеке, так и в куче, чтобы лучше показать различия в поведении, мы разработали небольшую утилиту, которая выдаст нам адрес рассматриваемых объектов, поэтому, сравнивая адреса, будет легко увидеть, говорим ли мы об одном и том же или о разных объектах. Единственная проблема заключается в том, что наша утилита поиска адресов имеет ограничение, то есть она работает ТОЛЬКО для объектов в куче, которые не содержат других объектов в куче (ссылок). Поэтому мы вынуждены использовать в наших объектах только примитивные значения, и именно по этой причине мне нужно было избегать использования «строки» C# и я использую только типы «char».

Вот эта «утилита поиска адреса». Мы создали два из них, один для объектов на основе классов, а другой для объектов на основе структур. Проблема в том, что мы хотим избежать упаковки объектов на основе структур, поскольку это даст нам адрес в куче упакованного объекта, а не в стеке исходного объекта. Мы используем Generics для блокировки некорректного использования утилит.

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;
}

 

2. Пример изменяемого объекта (на основе классов)

Вот пример изменяемого объекта на основе классов, то есть он находится в управляемой куче. И есть пробное исполнение и мутация. И вот результат выполнения:

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]) , а присваивание — это просто присваивание ссылок, указывающих на один и тот же объект. Итак, присваивание просто скопировало ссылку, и у нас есть случай, когда две ссылки указывают на один объект в куче, и не имеет значения, какую ссылку мы использовали, этот объект был видоизменен.

 

3. Пример изменяемого объекта (на основе структуры)

Вот пример изменяемого объекта, основанного на структуре, что означает, что он находится в стеке. И есть пробное исполнение и мутация. И вот результат выполнения:

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
*/

 

Как мы прекрасно знаем, структуры имеют « семантику значений » ([3]), и при присваивании экземпляр типа копируется. Это поведение отличается от поведения объектов на основе классов, то есть ссылочных типов, как показано выше. Как мы видим, присваивание создало новый экземпляр объекта, поэтому мутация затронула только новый экземпляр.

 

4. Пример неизменяемого объекта (на основе структуры)
4.1 Метод 1 — свойства только для чтения

Вы можете создать неизменяемый объект типа на основе структуры, пометив все общедоступные свойства ключевым словом « только для чтения ». Такие свойства могут быть изменены ТОЛЬКО на этапе строительства объекта, после этого они неизменны. В этом случае установка свойств на этапе инициализации объекта невозможна.

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 };

 

4.2 Метод 2 — свойства инициализатора

Вы можете создать неизменяемый объект типа на основе структуры, пометив все общедоступные свойства ключевым словом « init » для установщика. Такие свойства могут быть изменены ТОЛЬКО на этапе построения объекта и на этапе инициализации объекта, после чего они неизменны. В этом случае возможна установка свойств на этапе инициализации объекта.

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
};

 

4.3 Метод 3 — структура только для чтения

Вы можете создать неизменяемый объект типа на основе структуры, пометив структуру ключевым словом « только для чтения ». В такой структуре все свойства должны быть помечены как «только для чтения» и могут быть изменены ТОЛЬКО на этапе построения объекта, после чего они неизменны. В этом случае установка свойств на этапе инициализации объекта невозможна. Я не вижу никакой разницы в этом случае с методом 1 выше, когда все свойства/методы помечены как «только для чтения», за исключением того, что в определении уровня структуры легко увидеть, какова цель этой структуры, то есть создатель структуры планировал, что она будет неизменной из начало. 

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 };

 

5. Пример неизменяемого объекта (на основе класса)

5.1 Метод 1 — Свойства только для чтения

Вы можете создать неизменяемый объект типа на основе класса, сделав все общедоступные свойства доступными только для чтения, удалив сеттеры. Такие свойства могут быть изменены только закрытыми членами класса. В этом случае установка свойств на этапе инициализации объекта невозможна.

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 };

 

5.2 Метод 2 — свойства инициализатора

Вы можете создать неизменяемый объект типа на основе класса, пометив все общедоступные свойства ключевым словом « init » для установки. Такие свойства могут быть изменены ТОЛЬКО на этапе построения объекта и на этапе инициализации объекта, после чего они неизменны. В этом случае возможна установка свойств на этапе инициализации объекта.

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
};

 

6. Внутренняя неизменность против наблюдаемой неизменности

Все вышеперечисленные случаи были случаями «внутренней неизменности» неизменяемых объектов. Приведем пример одного неизменяемого объекта «Наблюдательная неизменность». Ниже приводится такой пример. Мы в основном кэшируем результат длительного расчета цены. Объект всегда сообщает об одном и том же состоянии, поэтому он удовлетворяет «наблюдательной неизменности», но его внутреннее состояние изменяется, поэтому он не удовлетворяет «внутренней неизменности».

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 calcualtion
        _price += 10_000;
        Thread.Sleep(1000); //long engine price calcualtion
        _price += 10_000;
        Thread.Sleep(1000); //long tax calcualtion
        _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
*/

 

7. Потокобезопасность и неизменность

«Внутренняя неизменность» Неизменяемые объекты тривиально потокобезопасны. Это следует из простой логики, что все «общие ресурсы» доступны только для чтения, поэтому потоки не могут мешать друг другу.

«Наблюдательная неизменность» Неизменяемые объекты не обязательно потокобезопасны, и приведенный выше пример показывает это. Получение состояния вызывает некоторые закрытые методы, небезопасные для потоков, и конечный результат не является потокобезопасным. При обращении из двух разных потоков указанный выше объект может сообщать о разных состояниях.

 

8. Неизменяемый объект (на основе структуры) и неразрушающая мутация

Если вы хотите повторно использовать неизменяемый объект, вы можете ссылаться на него столько раз, сколько хотите, потому что он гарантированно не изменится. Но что, если вы хотите повторно использовать некоторые данные объекта Immutable, но немного изменить их? Вот почему они изобрели «Неразрушающую мутацию». В языке C# теперь для этого можно использовать ключевое слово with . Как правило, вы хотите сохранить большую часть состояния объекта Immutable, но изменить только некоторые свойства. Вот как это можно сделать в 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
*/

 

9. Неизменяемый объект (на основе классов) и неразрушающая мутация

Для неизменяемых объектов на основе классов они не расширили язык 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);
//the following line will not compile
//CarClassI4 car2 = car1 with { Brand = "P" };
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
*/

 

Заключение

Шаблон неизменяемого объекта очень популярен и часто используется. Здесь мы представили введение в создание неизменяемых структур и классов в C# и несколько интересных примеров.

Мы обсудили «внутреннюю неизменность» и «наблюдательную неизменность» и поговорили о проблемах безопасности потоков.

Связанные концепции, которые могут быть рекомендованы читателю, — это «Объекты значений» и «Записи» в C#.