美文网首页
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