美文网首页
C# In Depth — Mutable Structures

C# In Depth — Mutable Structures

作者: f229e9496244 | 来源:发表于2018-10-21 01:24 被阅读28次

    摘要

    最近在Udemy上学习了C#进阶知识,以后会定期分享一下。
    本篇文章主要讨论在C#中Mutable Structures可能会带来的问题及如何避免。

    Struct & Class区别

    当面试官问到在C#中,Struct和Class之间有何区别的时候,相信大部分的回答是:

    Class对象是引用类型,其数据存储在堆中;
    Struct对象是值类型,其数据存储在栈中。

    关于引用类型和值类型的区别,相信很多面试官会问。这里用两幅图回忆一下,图片来自《C# 图解教程》:

    给出一个经常看到的实例:

        public struct StructTest
        {
            public int X;
    
            public StructTest(int x)
            {
                X = x;
            }
        }
    
        public class ClassTest
        {
            public int X;
    
            public ClassTest(int x)
            {
                X = x;
            }
        }
    

    上面的代码,先分别给出一个结构体和一个类
    随后测试代码:

      public static void Main(string[] args)
            {
                //分别创建结构体对象、类对象
                var structObject = new StructTest(1); 
                var classObject = new ClassTest(1);
                
                //分别将两个对象进行复制,得到两个新的副本对象
                var structObject1 = structObject;
                var classObject1 = classObject;
                
                //更改副本对象中的成员
                structObject1.X++;
                classObject1.X++;
    
                //查看原始对象中的成员
                Console.WriteLine(structObject.X);
                Console.WriteLine(classObject.X);
            }
    

    打印结果如下:

    1
    2
    

    显而易见证实了这一点。

    Tips: 在Visual Studio中可以打断点查看,然后在监视窗口中输入: '&'+变量名;可以看到变量的物理地址;

    另外我还不太清楚如何在Jetbrain Rider中查看变量的地址,如果有读者知道请在评论区告诉我;


    当然,这样回答完全正确,但是更专业的回答是:

    Structures implement the semantic of copying by value, whereas classes implement the semantic of copying by reference.

    可以理解为:结构体对象的复制行为从语法角度来说是值传递,而类对象的复制行为从语法角度来说是引用传递。


    Mutable:可异变的。
    下面分享一下Mutable Structures可能会带来的意外问题以及如何避免。

    案例&分析:

    案例1:

    //使用Struct后可能会出现问题的代码
     public struct Customer {
        public int Age { get; private set; }
    
        public Customer(int age) : this() {
            Age = age;
        }
    
        public int IncrementAge() {
            Age++;
            return Age;
        }
    }
    
      public class CustomerClient {
        public Customer PropertyCustomer { get; } = new Customer(1);
        public readonly Customer ReadonlyCustomer = new Customer(1);
        public Customer FieldCustomer = new Customer(1);
    }
    
    在上面的代码中,新建了一个结构体Customer,里面有:
    • 一个Int类型成员变量Age;
    • 还有一个方法IncrementAge(),它负责使Age自增1,并返回Age的值。

    很快,你就会发现这样做出现问题了!

    接下来有一个类CustomerClient,里面有三个Customer类型的成员,这三个成员分别如下:
    • 第一个是仅可访问的属性PropertyMutable;
    • 第二个是添加了readonly关键字的成员;
    • 第三个是没有任何关键字修饰的成员;

    测试:

    测试的过程:分三组;每组先打印结构体成员中的Age,再调用自增方法,最后再打印一次Age,看有没有增加;

      public class CustomerClient {
         public void Test() {
            Console.WriteLine("Property case");
            Console.WriteLine((int) PropertyCustomer.Age);
            PropertyCustomer.IncrementAge();
            Console.WriteLine((int) PropertyCustomer.Age);
    
            Console.WriteLine("Readonly case");
            Console.WriteLine((int) ReadonlyCustomer.Age);
            ReadonlyCustomer.IncrementAge();
            Console.WriteLine((int) ReadonlyCustomer.Age);
    
            Console.WriteLine("Field case");
            Console.WriteLine((int) FieldCustomer.Age);
            FieldCustomer.IncrementAge();
            Console.WriteLine((int) FieldCustomer.Age);
        }
    }
    
      class Program
      {
        public static void Main(string[] args)
        {
          CustomerClient a = new CustomerClient();
          a.Test();
        }
      }
    

    测试结果:

    Property case
    1
    1
    Readonly case
    1
    1
    Field case
    1
    2
    
    Process finished with exit code 0.
    
    

    分析:为什么会出现这种情况?

    1. 第一个试验,属性中的get访问器从语法上来讲:它返回一个拷贝。
      因此每次get得到的Customer对象,都要先发生复制动作。之前说过:结构体的复制语义上都是值传递。因此,每次get结构体实例的时候,都是重新拷贝一个结构体实例。

    2. 第二个试验,看起来我们直接获取到了结构体变量。但是有readonly这个关键字修饰。经过readonly关键字修饰的变量在初始化之后就无法更改了;因此等同于,编译器做了如下操作:

    Customer tmp1 = a.ReadonlyCustomer;
    Console.WriteLine(tmp1.Age);
    
    Customer tmp2 = a.ReadonlyCustomer;
    tmp2.IncrementAge();
    
    Customer tmp3 = a.ReadonlyCustomer;
    Console.WriteLine(tmp3.Age);
    
    

    编译器默默对其进行了拷贝,防止对其进行修改

    其实在微软官方文档也说明了:https://docs.microsoft.com/en-us/dotnet/csharp/language-reference/keywords/readonly

    The readonly modifier on a struct definition declares that the struct is immutable. Every instance field of the struct must be marked readonly

    注意:以下代码为C# 7.2及以上的语法特性

    public readonly struct Point
    {
        public double X { get; }
        public double Y { get; }
    
        public Point(double x, double y) => (X, Y) = (x, y);
    
        public override string ToString() => $"({X}, {Y})";
    }
    

    The preceding example uses readonly auto properties to declare its storage. That instructs the compiler to create readonly backing fields for those properties.

    意思是说这样定义的readonly的结构体是Immutable的;并且,编译器会默认给里面所有的属性成员赋予readonly关键字,等同于下面这段:

    public readonly struct Point
    {
        public readonly double X;
        public readonly double Y;
    
        public Point(double x, double y) => (X, Y) = (x, y);
    
        public override string ToString() => $"({X}, {Y})";
    }
    

    Adding a field not marked readonly generates compiler error CS8340: "Instance fields of readonly structs must be readonly."
    如果struct使用了readonly修饰,而它的某些实例字段没有使用了readonly,编译器则会报错

    错误CS8340
    1. 第三个试验,并没有复制的动作。每次调用的都是a的引用中的结构体实例,因此每次操作都会对之前的数据产生影响。

    改进方法:

    那么如何改进代码,避免出现这种Mutable Structures呢?除了前面所说可以用readonly字段加以修饰之外,还可以用以下方法去避免:

    struct Point {
        public int X { get; }
        public int Y { get; }
    
        public Point(int x, int y) : this() {
            X = x;
            Y = y;
        }
    
         public Point Increment(int x, int y) {
            return new Point(x, y);
        }
    }
    

    首先,将结构体中的所有成员变量设定为只读属性,一旦初始化后就保证之后会对其修改;其次,方法体返回一个新对象,保证了返回的对象是一个副本。

    对之前的代码进行修改:

      struct Customer {
        public int Age {get;}
    
        public Customer (int age) : this() {
          Age = age;
        }
    
        public int IncrementAge()
        {
          var age= Age;
          age++;
          var tmp = new Customer(age);
          return tmp.Age;
        }
      }
    

    通过修改,结果也变为1,1了


    案例2:通过数组索引访问成员发生异变

    结构体还是和上面一样,只不过换了个名字:

    struct Customer {
            public int Age { get; private set; }
    
            public Customer(int age) : this() {
                Age = age;
            }
    
            public int IncrementAge() {
                Age++;
                return Age;
            }
        }
    

    然后测试代码如下:

    public class CustomerClient {
            public void Test() {
                var list = new List<Customer> {new Customer(age: 5)};
    
                Console.WriteLine("Initial Age: {0}", list[0].Age);
                list[0].IncrementAge();
                Console.WriteLine("Modified Age: {0}", list[0].Age);
    
                //----------
    
                var array = new[] {new Customer(age: 5)};
                Console.WriteLine("Initial Age: {0}", array[0].Age);
                array[0].IncrementAge();
                Console.WriteLine("Modified Age: {0}", array[0].Age);
            }
        }
    

    测试代码分为两部分:

    • 第一部分为List列表的操作,使用一对花括号初始化List列表,然后取出列表中的第一个元素进行操作,随后检查结果是否增加;
    • 第二部分为数组对象,同样初始化数组后取出第一个元素进行操作,再查看结果是否有改变。

    测试结果:

    Initial Age: 5
    Modified Age: 5
    Initial Age: 5
    Modified Age: 6
    

    分析:

    在测试1,实际上是使用了索引器的方式对List数组进行了访问,索引器在很大程度上类似于属性。那么返回的值,也就和案例一种通过属性访问是一样的道理了。因此就算增加的只是一份值拷贝,执行完后,值就销毁了。

    在测试2,看起来和上面相同,也是通过索引进行访问。并且,C#不支持托管指针,不能返回对类的字段的引用以获取实例。然而,编译器能够为了让我们在数组中访问元素对其做了优化(IL-instruction(idelema)能够使我们获取到数组元素的引用拷贝而不是值的拷贝)
    这里我也不是太清楚,具体还要去看看CLR Via C#一书是怎么说的


    案例3:

     public struct MyHandle : IDisposable {
            public IntPtr Handle { get; private set; }
    
            public MyHandle(IntPtr preexistingHandle) {
                Handle = preexistingHandle;
            }
    
            public void Dispose() {
                if (Handle != IntPtr.Zero)
                    CloseHandle(Handle);
                Handle = IntPtr.Zero;
            }
    
            [ReliabilityContract(Consistency.WillNotCorruptState, Cer.Success)]
            [DllImport("kernel32.dll", SetLastError = true)]
            internal static extern bool CloseHandle(IntPtr handle);
        }
    

    实际上,在.Net框架中,DateTime和TimeSpan都是结构体。其中,DateTime有一个方法是AddHours,例如下面这段代码:

    var dateTime = new DateTime();
    dateTime.AddHours(1);
    Console.WriteLine(dateTime);
    
    发现时间并不会改变;可以看到源码中: 返回了一个新的结构体对象

    近况

    最近项目特别多,特别特别累。
    十一去了神农架,好好放松。那里景色特别美:


    这两张看起来像水墨画 雾渐渐消散,好像看到了彩虹

    这是用手机拍出来照片,效果还不错;
    呃,攒钱买单反吧 :)

    相关文章

      网友评论

          本文标题:C# In Depth — Mutable Structures

          本文链接:https://www.haomeiwen.com/subject/bvtvzftx.html