.NET C#基础(4):属性 - 本质是方法

0. 文章目的

  本文面向有一定.NET C#基础知识的学习者,介绍C#中属性的属性、定义、使用方法以及特殊性。

 

1. 阅读基础

  理解C#基本语法(定义类及类成员,调用方法)
  认可OOP的封装思想
 
 

2. 属性是什么:从字段与封装说起

  属性的本质是对字段的封装,要理解这一概念,首先要理解为什么需要对字段进行封装。因此,我们首先从实际需求入手,了解对字段封装的意义。

  现在假设我们需要写一个Cat类,类可以储存CatID、Name与Age三个状态分别表示Cat的标识号,名字与年龄,并且这些状态可以被外部访问和修改。这个类看起来很简单,新手如我们也能很快写出下述代码:

class Cat
{
    public string CatID;
    public string Name;
    public int Age;
}

  在这里,我们定义了一个Cat类,并定义了三个‘变量’来表示CatID、Name与Age,这种用于记录对象状态的的‘变量’就被称为字段。

  现在我们把Cat类提交了上去,这个类在大多数情况下都能正常表现,直到有一天有人发现程序出现了一些诡异的错误,定位错误后发现是由于某个Cat对象的Age字段被错误赋值了负数:

cat.Age = -1;

  这个错误被很快修正,但是显然为了避免后面类似的情况再次发生,我们应该注意到在对Age字段赋值前应当对值做一个校验:如果值小于0,就当作0处理(当然,抛出异常是更好的选择,但这里不过多引入新概念)。显然字段没有这种数据校验的能力。要解决这个问题,我们可以使用一个方法将对字段的赋值进行包装,就像下面这样:

class Cat
{
    // ... 省略无关代码
    public int Age;

    public void SetAge(int value)
    {
        Age = value;
    }
}

(显然,SetAge需要一个参数value指示待赋值的值,至于返回值则没有必要)

  在方法里就可以写赋值的判断逻辑了,因此我们在SetAge方法中添加对值的校验:

public void SetAge(int value)
{
    if (value < 0)
    {
        value = 0;
    }
    Age = value;
}

  这样,就可以保证在对字段Age赋值之前保证值大于等于0了。当然还有一个问题,仅仅这样你依然无法阻止使用者直接对Age字段赋值,还是有可能出现之前的错误。因此,还需要阻止外部对字段Age的直接赋值,在C#中,可以通过将字段的可访问性设置为private来实现:

class Cat
{
    // ... 省略无关代码
    private int Age; // 现在字段只有类内部可以访问和修改了

    public void SetAge(int value)
    {
        Age = value;
    }
}

(通常,类成员的可访问性应当尽可能设置为最大的访问限制)

  另外,我们应该还可以允许获取字段Age的值,否则Age字段就没有什么意义了。为此,我们还需要提供一个方法来获取Age的值,如下:

class Cat
{
    // ... 省略无关代码
    private int Age; // 现在字段只有类内部可以访问和修改了

    public void SetAge(int value)
    {
        // ... 省略无关代码
    }
    public int GetAge()
    {
        return Age;
    }
}

(显然,获取Age的不需要提供什么参数,但需要一个返回值来获取到Age的值)

  这样,我们就对字段的访问和修改进行了包装,可以不用担心再出现Age被赋值为负数的情况,同时可以正常地获取Age的值。经过上面的封装,从概念上来讲,我们就定义了Age这个属性。基于这个思路,我们也可以将其他字段进行封装为属性,来避免以后可能出现的对字段校验的需求:

class Cat
{
    private string CatID;
    private string Name;
    private int Age;

    public string GetCatID() { ... }
    public void SetCatID(string value) { ... }

    public string GetName() { ... }
    public void SetName(string value) { ... }

    public string GetAge() { ... }
    public void SetAge(int value) { ... }
}

  上面就是如何封装字段的示例。我们应该意识到将表示状态的字段封装为属性可以获得对数据校验的能力,从而有效减少编程时可能的因为各种原因而导致的不合理赋值,而其代价最多也就仅仅是多一层方法调用而已。

 

3. C#中的属性:定义、使用与本质

3.1 定义

  现在回过去看Cat,你会发现它似乎有点复杂:

class Cat
{
    private string CatID;
    private string Name;
    private int Age;

    public string GetCatID() { ... }
    public void SetCatID(string value) { ... }

    public string GetName() { ... }
    public void SetName(string value) { ... }

    public string GetAge() { ... }
    public void SetAge(int value) { ... }
}

  第一个问题是,我们定义了三个字段,却为此定义了六个方法,更糟糕的是,这些方法似乎都遵顼一定的规律:

  1. 获取字段值的方法以Get+字段名为方法名,不接受任何参数,一个字段类型的返回值
  2. 设置字段值的方法以Set+字段名为方法名,接受一个参数用于表示待赋值的的值,没有返回值。方法大多数时候只是简单返回值。

  显然这种枯燥的重复性工作应该交给代码生成器自动完成(实际上在一些语言的IDE中确实提供了类似于上述的代码生成的快捷方式)。

  另一个问题是,属性的使用并不方便,设置于获取值都需要使用方法调用的方式,这一调用略显繁琐,并且不太直观:

Cat cat = new Cat();

cat.SetAge(1);
Console.WriteLine(cat.GetAge());

  这两个问题带来的不便在很大程度上阻碍了程序员使用属性。C#在语法层面提供了对于属性定义的支持,有效缓解了上述两个问题,一个属性的定义通常为以下结构:

class Foo
{
    public int Property
    {
        get
        {
            return ...;
        }
        set
        {
            ... = value;
        }
    }
}

  其中的get块被称为get访问器,就相当于原本的GetXXX方法,而set块则被称为set访问器,就相当于原本的SetXXX方法。显然这种语法减少了实现相应方法所需要书写的内容,例如下面是使用C#中的属性定义语法来定义Age属性的示例:

class Cat
{
    private int _age; // 实际储存Age属性值的字段

    public int Age
    {
        get
        {
            return _age;
        }
        set
        {
            if (value < 0)
            {
                value = 0;
            }
            _age = value;
        }
    }
}

  注意在上述方法声明中,有两个地方需要注意:

  1. 类中使用了一个名为_age的字段。请注意属性本质是方法,它无法储存值,要储存值就需要通过其他方法,通常就是提供一个字段来储存属性的值,这样的字段被称为后备字段(Backing Field)。
  2. set方法使用了一个此前从未定义过名为value的变量,这个变量用于指代要赋值给字段的目标值,这是C#语言提供的语法支持。

  属性的定义问题得到了简化,而对于使用,C#的属性则完全消除了使用属性时额外的书写负担。下面是使用属性的示例:

Cat cat = new Cat();

cat.Age = -1; // 此时,set块中的value的值就是-1
Console.WriteLine(cat.Age);

  可以看到使用属性的和使用字段在语法上没有任何差别,也就是说,使用属性后,就不必像之前那样按照方法调用的方式来获取或设置值,大大降低了属性的使用成本,并且让属性的作用有更直观的表现。

3.2 本质

  属性的本质是方法,也就是说,下述对属性的定义与使用:

class Cat
{
    private int _age; // 实际储存Age属性值的字段

    public int Age
    {
        get { ... }
        set { ... }
    }
}

Cat cat = new Cat();
cat.Age = -1; 
int catAge = cat.Age;

  实际上作用等同于下述代码:

class Cat
{
    private int _age; // 实际储存Age属性值的字段

    public void SetAge(int value) { ... }
    public int GetAge() { ... }
}

Cat cat = new Cat();
cat.SetAge(-1);
int catAge = cat.GetAge();

  其中相应的Get方法和Set方法都由编译器自动生成,并在需要的地方进行转化,例如:

class Cat
{
    private int _age; 

    public int Age
    {
        // 编译时转化为 public int get_Age() { ... }
        get { ... } 
        // 编译时转化为 public void set_Age(int value) { ... }
        set { ... } 
    }
}

Cat cat = new Cat();
cat.Age = -1;  // 编译时转化为 cat.set_Age(-1);
int catAge = cat.Age // 编译时转化为 int catAge = cat.get_age();

  因此请记住这一点:属性的本质是方法。这对于理解属性的行为有重要作用。

3.3 再谈访问器

  有时候可能需要定义一种只读属性,即外部只能获取属性值而无法设置属性值,例如Cat类的CatID不应该被随意更改。那么可以通过只定义GetXXX的方法来达到此目的,例如:

class Cat
{
    private string CatID;

    public string GetCatID()
    { 
        return CatID;
    }
}

  这样,CatID字段的值设定就只能发生在类内部了,外部只能访问而不能修改。而在C#中,则可以通过只定义get访问器来达到此目的:

class Cat
{
    private string _catID; // 实际储存Age属性值的字段

    public string CatID
    {
        get
        {
            return _catID;
        }
    }
}

(当然,你也可以只定义set访问器来定义只写属性,但是显然只写属性没有太大的意义)

  此外,你可以为访问器指定访问修饰符来限制某一访问器的访问范围,如下:

class Cat
{
    private string _catID; // 实际储存Age属性值的字段

    public string CatID
    {
        get
        {
            return _catID;
        }
        protected set
        {
            _catID = value;
        }
    }
}

  上面例子中CatID属性的set访问器为protected,即意味着其set访问器只能被类及其子类访问。上述声明等同于下:

class Cat
{
    private string _catID; // 实际储存Age属性值的字段

    public string get_CatID()
    {
        return _catID;
    };

    protected void set_CatID(string catID)
    {
        _catID = value;
    }
}

  注意访问器的访问限制必须比本身的属性的访问限制更强,也就是说你不能为一个访问限制为protected的属性声明一个public的访问器,如下:

protected string Name
{
    public get
    {
        return ...;
    }
}

  同样基于这个原因,将访问器修饰为public是没有意义的(public就是最弱的访问限制,不可能比属性本身的访问限制还弱)。此外,如果指定了访问器的访问修饰符,那么就必须同时声明get和set访问器,显然这是理所当然的。

 

4. C#中属性的语法糖

  为了提高属性的使用率,C#还为属性提供了多种简化声明的语法糖,下面同样对这些语法糖做一一介绍。

4.1 自动属性

(1)声明

  一个自动属性的声明如下:

public string Name { get; set; }

  其等效于下述代码:

private string _name;
public string Name
{
    get
    {
        return _name;
    }
    set
    {
        _name = value;
    }
}

  同样,你可以为自动属性添加访问修饰符用来限制访问器的访问:

public string Name1 { get; private set; } // 外部只读属性
public string Name2 { private get; set; } // 外部只写属性

  同样,也可以仅声明get访问器来指示这是一个只读属性:

public string Name { get; } // 只读属性

  只读属性类似于只读字段,初始化与赋值只能在构造方法中完成,其本质上也是对只读字段的属性包装。

  尽管自动属性没有任何数据校验,但是自动属性的最大的意义在于简化属性声明。声明为属性有特殊意义,这意味着如果在此之后如果由于需求变动需要为属性添加数据校验或其他操作,只需要简单地将自动属性手动实现为完整属性并重新编译本程序集即可,而不需要重新编译之前使用了该属性的程序(请注意,尽管属性的使用在代码层面看起来与使用字段相同,但是对于CLR来说,属性的获取与赋值是依赖方法调用,和直接访问字段有巨大差别)。另外,自动属性还可以方便声明虚属性以及方便在接口中定义属性。

(2)问题

  编译器会为自动属性生成需要的后备字段,然而其生成的后备字段的字段名是不确定的。这意味着如果你的类需要进行序列化,则不应该使用自动属性,而是使用完整属性声明与明确的后备字段名。

4.2 =>表达式

  你可以通过使用类似于Lambda表达式的=>去声明set访问器与get访问器的主体:

private string _name;
public string Name
{
    get => _name;
    set => _name = value;
}

  此类表达式只是完整声明语法的语法糖,因为它在编译期就会被翻译为完整属性的声明。

  另外,如果想声明只读属性,可以使用以下语法:

private string _name;
public string Name => _name;

  上述写法等效于:

private string _name;
public string Name
{
    get
    {
        return _name;
    }
}

4.1 init访问器

  除了get和set访问器外,属性还有一种被称为init的访问器,此访问器用于替换set访问器,表示属性的赋值只能在构造方法或‘对象初始值设定项’语句中完成。如有以下类定义:

class Cat
{
    public string Name { get; init; }
}

  那么Name属性就只能在Cat类的构造方法或者在‘对象初始值设定项’语句中完成赋值。也就是说,是下述情况:

class Cat
{
    public string Name { get; init; }

    public Cat()
    {
        Name = "AAA"; // 可以,在构造方法中赋值
    }
}


Cat cat = new Cat()
{
    Name = "BBB" // 可以,在‘对象初始值设定项’块中赋值
};

cat.Name = "CCC"; // 错误

  init访问器的实现同样是对只读字段的封装,与只声明get访问器的区别在于,声明init访问器后属性还可以通过‘对象初始值设定项’完成赋值。



5. 特殊属性

5.1 带参属性(索引器)

(1)含义

  尽管大多数情况下获取属性值不需要参数,但有时候对于一些特殊属性确实需要传递参数,考虑下面的例子:

class IntArray
{
    private int[] array = { 1, 2, 3 };
}

  IntArray是一个对int数组的包装,现在假设需要获取里面array数组的第一个元素的值,那么该如何获取?一种办法是,直接获取array的引用并直接取下标:

class IntArray
{
    private int[] array = { 1, 2, 3 };

    public int[] GetArray()
    {
        return array;
    }
}

IntArray intArray = new IntArray(); 
int[] array = intArray.GetArray();
int n = array[0];

  这是可以的,但是这一过程略显麻烦,其实我们可以考虑只传递一个下标作为参数并直接返回内部array数组中相应下标的值:

class IntArray
{
    private int[] array = { 1, 2, 3 };

    public int GetItem(int index)
    {
        return array[index];
    }
}

IntArray intArray = new IntArray(); 
int[] array = intArray.GetItem(0);

  我们依然可以将GetItem视为一个获取属性的方法,但是与一般属性的Get方法不同的是,GetItem方法还需要额外传递一个参数,这种需要额外参数的属性被称为有参属性。C#中的属性通常指无参属性,不过C#依然提供了定义有参属性的方法,这类方法被称之为‘索引器’。上述例子使用索引器改写后如下:

class IntArray
{
    private int[] array = { 1, 2, 3 };

    public int this[int index]
    {
        get
        {
            return array[index];
        }
    }
}

  对于上面定义的有参属性(索引器),使用方法如下:

IntArray intArray = new IntArray(); 
int[] array = intArray[0];

  从某种意义上来说,你可以将索引器视为C#中重载[]运算符的方法。

(2)特点

  和属性相同,索引器可以定义get访问器和set访问器,也可以只定义get访问器或者只定义set访问器,并添加需要的访问修饰符。换句话说,应该认识到索引器就是属性,只是可以允许传入额外参数的属性,因此也可以称其为有参属性,也因此可以像对待普通属性那样对待有参属性。

  你可能会好奇既然索引器是属性,那么为何没有属性名?实际上编译器会为索引器生成一个隐藏的属性定义,通常这个属性名叫做‘Item’。也就是说,下述代码:

class IntArray
{
    private int[] array = { 1, 2, 3 };

    public int this[int index]
    {
        get
        {
            return array[index];
        }
    }
}

  在概念上等效于:

class IntArray
{
    private int[] array = { 1, 2, 3 };

    public int get_Item(int index)
    {
        return array[index];
    }
}

  因此有意思的一点是,默认情况下你不能同时声明索引器与Item属性,就像下面这样:

class IntArray {
    public int Item { get; }

    public int this[int index] { ... } // 编译错误,Item属性已定义
}

  不过你可以使用IndexerNameAttribute来修改编译器为索引器生成的隐藏属性名,string类型便使用此特性将其索引器的隐藏属性名修改成了更有意义的Chars。

  了解这有一点,你就知道如果通过反射属性时发现某个类型中存在名为Item的属性,那么这个类型大概率就定义了索引器。

5.2 静态属性

  属性也可以通过添加static修饰符来将其声明为静态属性,如下:

class Foo
{
    public static int Total { get; set; }
}

  静态属性和实例属性的实质没有区别,区别仅仅在于静态属性对应的Get和Set方法是static方法而已,也就是说,上述例子等同于下:

class Foo
{
    public static int GetTotal() { ... }
    public static void SetTotal(int value) { ... }
}

  因此如何使用静态方法就如何使用静态属性。然而有一点需要说明的,你不能声明静态有参属性(静态索引器):

class Foo
{
    public static int this[int index] { ... } // 不允许的声明
}

int value = Foo[0]; // 错误,静态有参属性无法声明

  索引器只是有参数属性,因此从理论上讲,静态有参数属性依然可以转化为等价的方法调用,因此这并非是因为CLR(公共运行时)不支持,仅仅只是C#不支持而已。一个原因可能是因为C#中声明索引器需要用到this关键字,显然this这对于静态成员来说是不存在的。当然,这一限制通常不会是什么问题。

5.3 依赖属性

  依赖属性是WPF中的概念,一个依赖属性的定义可能如下:

class Cat
{
    public static readonly DependencyProperty AgeProperty =
        DependencyProperty.Register("Age", typeof(int), typeof(Cat), new PropertyMetadata(0));

    public int Age 
    {
        get 
        { 
            return (int)GetValue(AgeProperty); 
        }
        set 
        { 
            SetValue(AgeProperty, value); 
        }
    }
}

  上面的声明了一个名为AgeProperty的依赖属性,并提供了一个使用依赖属性AgeProperty储存值的名为Age的普通属性。请注意依赖属性指的仅仅是上面类型为DependencyProperty的AgeProperty,Age的实现依然类似于普通属性,和普通属性的区别只在于其获取与设置值依赖于依赖属性而非后备字段。

  WPF中的依赖属性提供了对数据绑定的支持,数据绑定是MVVM模式的核心概念,因此了解依赖属性对编写WPF程序有重要意义。由于本文重点不在于此,故不做深入介绍。

 

6. 属性杂谈

6.1 使用属性的理由

  在.NET中,属性的使用应该像呼吸一样自然,有许多理由去使用属性而非直接使用字段,下面列举几个常见的理由:

(1)减少错误

  正如开头的例子一样,利用属性可以为字段提供数据校验功能,有助于减少编程中可能的意外错误。

(2)性能

  你可能会认为属性多了一层方法调用会降低性能,但实际上通常来说多的一层方法调用并不会对性能有什么显著的的影响,不应该为了这点微不足道的性能而牺牲可靠性与安全性。另外,JIT对于简单实现(即没有复杂代码)的属性方法调用可能会进行内联,这意味此时属性的使用将没有额外的性能开销。

(3)方便

  C#为属性提供了语法上的声明支持,有效减少了声明为属性需要编写的代码并消除了使用属性时的不便。即便字段此时无需数据校验,将其声明为自动属性也几乎没有什么额外的工作量,并且还对未来可能要进行的扩展提供了保障。

(4)不得不用

  有时候你必须使用属性,例如在WPF,数据绑定的数据源必须是属性。

6.2 不使用属性的理由

  尽管属性有多种好处,但有时候你可能不得不使用字段,下面是一些可能的情况:

(1)使用值类型的属性与性能敏感的场合:

  考虑下述的结构体定义:

struct Vector2 
{
    public float x;
    public float y;
}

  接着定义一个Square类,使用一个Vector2类型的Position属性表示其坐标位置,然后实例化一个Square,并尝试将其Position属性的x的值修改为1:

class Square 
{
    public Vector2 Position { get; set; }
}

Square square = new Square();
square.Position.x = 1;

  代码从语法上看起来没什么问题,然而是无法通过编译的。错误提示为:无法修改square.Position的返回值,因为它不是变量。如果你能理解这个错误提示,应该就能意识到问题在哪了。前面说过,属性的本质是方法,因此上述的代码实际表现类似于:

// square.Position.x = 1;
square.get_Position().x = 1;

  请注意问题的根源在于Vector2是一个struct值类型,值类型作为返回值返回的是其副本,也就是所get访问器所获取的Vector2对象并非是Position的Vector2对象,而是Position的Vector2对象的一个副本。显然修改这个副本没有意义,因为这个副本在被修改后就会被丢弃,没有产生任何影响。所以,如果要修改Position的值,你不得不像下面这样:

Square square = new Square();
var position = square.GetPosition();
position.x = 1;
square.Position = position;

  显然这样存在一些问题,除了书写起来不便,多余的变量与赋值在对性能有严格要求的地方可能有巨大影响。记住这是由于属性是方法,所以其返回的只是一个副本,而如果是直接访问字段则不存在此问题:

class Square 
{
    public Vector2 Position;
}

Square square = new Square();
square.Position.x = 1;

  另外作为补充,如果非要使用属性,这里再提一个解决方法,就是使用ref将返回值标记为引用返回:

class Square
{
    private Vector2 _position;

    public ref Vector2 Position => ref _position;
}

  这样Position属性返回的就是实际字段的引用而非副本了,此时便可以直接修改Position的x。

Square square = new Square();
square.Position.x = 1;

// 下面是其等效代码
Square square = new Square();
ref Vector2 position = ref square.Position;
position.x = 1;

  不过需要注意,返回引用的属性无法定义set访问器,当然,这一设计可以理解。

(2)与本地代码交互

  有时候在通过P/Invoke调用本地代码的时候,需要按照本地代码的要求定义结构体,此时就需要直接使用字段以便与本地代码进行交互。

6.3 虚属性

  将属性声明为虚属性是可行的:

class Cat
{
    public virtual string Name { get; set; }
}

  这并不奇怪,前面说过,属性的本质是方法,因此子类重写虚属性就像重写普通方法一样自然。也正因为此,在接口中声明属性是可行的:

interface ICat
{
    string Name { get; set; }
}

  上述接口的实际定义如下:

interface ICat
{
    string get_Name();
    void set_Name(string value);
}

6.4 用于out与ref参数的属性

  如果你尝试将属性作为ref或者out参数传递给方法,编译器会报错:

class Cat
{
    public int Age { get; set; }
}

Cat cat = new Cat();

Print(ref cat.Age); // 错误,属性不可作为ref参数传递

  请记住属性的本质是方法,上述代码的实际含义是:

Print(ref cat.get_Age());

  ref或者out本质上用于获取目标变量的地址并允许在方法中直接操作该地址指向的数据,尽管你可以认为get_Name()会返回一个值,这个值应该也有相应的地址,但请记住方法返回的值是一个副本,因此可以预见这个值在方法跳出后就无法访问并且不会对现有数据产生实质影响,所以是一个没有意义的操作。

  不过例外的是,如果属性返回的是一个ref属性,那么属性还是可以作为ref或者out参数:

class Cat
{
    private int _age;

    public ref int Age => ref _age;
}

Print(ref cat.Age); // 允许

  因为此时属性返回的不再是副本,而是对字段的直接引用,故对其进行修改可以直接作用到原有字段上,因此该操作是有意义的。

6.5 缺陷与争论

  不可否认,C#对属性的语上的支持确实有效缓解了传统上定义和使用属性带来的诸多不便。然而,由于C#中属性的使用方法和字段几乎一致,对于初学者来说往往难以直观认识到属性的本质是方法。《CLR via C#》中就有提到过此问题,书的作者认为C#不应该提供现在的属性语法支持,而是使用更为传统的定义方法的方式来定义属性,让属性的本质更明确。

  C#中对属性的设计究竟是利大于弊还是弊大于利这里不做判断。但无论如何,认识到‘属性的本质是方法’是十分有必要的。

 

7. 属性规范

  在C#中,如果要更规范地使用属性,需要遵循一些设计原则。

(1)属性命名规范

  属性的命名应该可以直观的描述所指代的状态,并使用Pascal命名法。所谓Pascal命名法就是将名字中各个单词的首字母大写,如下是合适的属性命名:

public string Name { get; set; }
public bool IsCompleted { get; get; }

(2)后备字段命名规范

  后备字段应为私有字段,其命名应该与对应的属性名相同,但采用Camel命名法,Camel命名法类似于Pascal命名法,区别在于Camel命名法的首个单词首字母为小写。此外_ + Camel命名法,或者m_ + Camel命名法也是常用的命名法,下面是用于Name属性的后备字段使用三种命名法的示例:

private string name { get; set; }  // Camel命名法
private string _name { get; set; }  // _ + Camel命名法
private string m_name { get; set; } // m_ + Camel命名法

  不过,当前的建议是使用_ + Camel命名法,原因是因为部分语言不区分变量名大小写,这会导致命名冲突,比如Visual Basic。除此之外,与普通变量名的命名法加以区分,可以避免在方法中访问字段时需要额外添加this关键字。

posted @ 2022-06-05 21:55  HiroMuraki  阅读(1192)  评论(1编辑  收藏  举报