C#基础知识梳理系列三:C#类成员:常量、字段、属性
类 就像自然界的事物一样,拥有反应其自身状态特性的一系列数据。类状态数据是由常量、字段、属性等一些基础成员组成,且有静态和实例之分。它们之间有什么区别呢?可以互相替代吗?常量与静态只读字段有什么区别呢?属性是用方法实现,那么实现它的方法可以有参数吗?本章将解释这些奥秘。
常量是一个符号,是在编译时已经存在且在程序生命周期内不会发生改变的值,它被保存在程序集的元数据中,只能使用C#内置的数据类型(基元类型)定义,如:int、uint、long 等,当然不包括System.Object。既然是内置类型定义,它必然是在声明时同时已初始化。常量使用const定义,C#编译器总是默认为static成员,且不可明文指定其访问修饰符为static。常量的可访问修饰符为:public、private、protected、internal 或 protected internal。如下代码:
public class Code_03 { public const double PAI = 3.14; public void Test() { double area = PAI * Math.Sqrt(20); } }
IL如图:
通过IL我们可以看到,常量中定义的PAI值3.14(3.1400000000000001)实际是直接被编译到目标代码的元数据中的,而不是对PAI的引用。
如此一来,就引出一个问题:常量PI定义在程序集A中,如果程序集B在使用A中的PAI时,经过编译其真实值是直接被编译到B的IL中,当下一次我们要对PAI的值进行更改时(比如把3.14改为3.1415926),如果只编译程序集A而不编译程序集B,则会导致程序集B得不到更新,还保留原来的3.14,必须对程序集B重新编译才能获取新值3.1415926。要想解决此问题,可以使用接下来我们要讨论的字段,给字段加上readonly修饰符不仅可以达到与常量同样的目的:程序运行期间其值不可更改,而且可以避免每次重新编译程序集B。
字段是构成类结构的一种元素,它不仅可以用C#内置类型进行声明,也可以用任意的自定义类型进行声明,很明显,它不仅可以保存一个值类型的实例,也可以保存一个引用类型的地址引用。字段可以直接定义在类或结构中。相比常量,它就多了一些特性,它不仅可以是类的状态数据,也可以是实例的状态数据,它默认并不是static,而是对象级的成员,除非明确指定其修饰符为static。字段可以使用的修饰符为: public、private、protected、internal 或 protected internal。另外,readonly也可以用于字段,如果再加上satic,此时它就相当于常量了,只不过对象级的字段初始化是在构造函数中进行的,类级的字段初始化是在静态构造函数中进行的。如下代码:
public class Code_03 { public const double PAI = 3.14; double radius = 20; static int a = 10; static readonly int b = 30; }
可以看到,编译器自动生成了一个静态构造函数,并在其内对a和b进行初始化,另外在实例级构造函数内对radius进行初始化。需要说明一点的是:如果人为的要在构造函数中对常量PAI进行更改,在编译器检查语法过程中将会报错,编译器不允许这样操作。
常量是在编译时计算,字段是在运行时字段,并且常量与static readonly字段有着相同之处,那它们又有区别与联系呢?
const常量在声明处进行初始化,编译时直接将值编译进元数据,运行时不能进行值更改(如下面代码中的PAI)。
实例字段可在定义处和构造函数内进行初始化。可在任意处进行更改,如果其可访问性允许(如下面代码中的radius)。
static 字段声明为类级的字段,它属于类的状态数据。可在任意处进行更改,如果其可访问性允许(如下面代码中的a)。
readonly 字段声明只读字段,只能在构造函数内对其进行更改(如下面代码中的b)。
static readonly 字段声明静态只读字段,它属于类级且只读。只能在静态构造函数内对其更改(如下面代码中的c)。
示例代码:

public class Code_03 { public const double PAI = 3.14; double radius = 20; static int a = 10; readonly int b = 0; static readonly int c = 30; static Code_03() { a = 100; c = 1000; //错误 非静态的字段、方法或属性“ConsoleApp.Example03.Code_03.c”要求对象引用 //radius = 1; //b = -1; } public Code_03() { radius = 1; a = -1; b = -1; //错误 无法对静态只读字段赋值(静态构造函数或变量初始值中除外) //c = -1; } public void MyMethod() { radius = 1; a = -1; //错误 无法对静态只读字段赋值(静态构造函数或变量初始值中除外) //b = -1; //c = -1; } }
字段通常保存着类或对象本身的状态,我们当然可以将其公开为public让外界对其进行读、写修改。从某种意义上来讲,我们更希望在类本身内部对自己的状态进行维护,并不希望外界对自己的状态进行直接更改,以防止破坏这些数据,所幸的是还有一个数据成员可供使用,它就是属性。
如果在外部要访问某一个类的内部成员(私有字段),可以使用方法来达到目的,但如果对每一个字段都去编写一个方法来进行读写操作似乎又麻烦了些。属性以灵活的方式实现了对私有字段的访问,它是一种“访问器”方法,包括get方法和set方法,更明确地说,属性就是方法的精简写法的实现,隐藏了实现和验证的代码。它有两个访问器:
get访问器用于获取属性的值。
set访问器用于设定属性的值。既然它是方法,且是要在方法内对私有字段用新值进行更改替换,那么它就是可以(或者说是应该)接收参数的, value 关键字就是用于定义由 set 取值函数分配的值。假如有如下一个属性的定义:
public class Code_03_2 { string _name; public string Name { get { return _name; } set { _name = value; } } }
这个定义是通过属性Name对私有字段_name进行访问,我们来看一下编译器对IL做了哪些处理:
编译器自动生成了get_和set_方法。其中方法get_name()的IL如下:
.method public hidebysig specialname instance string get_Name() cil managed { // 代码大小 12 (0xc) .maxstack 1 .locals init ([0] string CS$1$0000) IL_0000: nop IL_0001: ldarg.0 IL_0002: ldfld string ConsoleApp.Example03.Code_03_2::_name IL_0007: stloc.0 IL_0008: br.s IL_000a IL_000a: ldloc.0 IL_000b: ret } // end of method Code_03_2::get_Name
它是在方法内部读取字段_name然后返回。
set_Name(string)方法如下:
.method public hidebysig specialname instance void set_Name(string 'value') cil managed { // 代码大小 9 (0x9) .maxstack 8 IL_0000: nop IL_0001: ldarg.0 IL_0002: ldarg.1 IL_0003: stfld string ConsoleApp.Example03.Code_03_2::_name IL_0008: ret } // end of method Code_03_2::set_Name
它是在方法内部对字段_name进行值修改。
我们已经看出来,属性是方法的实现,既然是方法,那方法就可以被访问修饰符定义,如public、private等,来限定方法的可访问性,很显然,我们也可以对属性的可访问性进行限定,这里是对访问器set和get进行访问限定的。比如:
string _name; public string Name { get { return _name; } private set { _name = value; } }
如此一来,则此属性Name是只读属性,如果愿意,也可以将其定义为只写属性,但这样做好像没什么意思。
另外,属性还有一种更简洁的写法如下:
public int Age { get; set; } public string Address { get; set; }
在编译的时候,编译器会自动生成对应的私有字段_age和_address,同样也会生成相应的get_方法和set_方法。
还有一种数据结构与属性很类似,称为索引器,它同样有get和set访问器,只是它的get方法接受大于或等于一个参数,它的set方法接受大于或等于两个参数。通常它在类内部维护一个集合,如Array、List等。如下代码:
public class Code_03_3 { string[] _nameList = new string[100]; public string this[int i] { get { return _nameList[i]; } set { _nameList[i] = value; } } }
this关键字用于定义索引器,value 关键字用于定义由 set 索引器分配的值。再来看一下编译器都干了什么事?
编译器自动生成了两个方法get_Item(int32)和set_Item(int32,string),它们接受了大于等于一个参数。最后要说明一点的是:索引器不必根据整数值进行索引,也可以用其他类型进行索引。如下的定义是用Guid进行索引:
Dictionary<Guid, string> data = new Dictionary<Guid, string>(); public string this[Guid key] { get { return data[key]; } set { data[key] = value; } }
我们一直在讨论属性是封装了对类内部私有字段成员的访问,它提供了一种书写更简便、访问控制更安全实现方式。根据建议,我们应该尽量避免在属性的访问器内进行过多的逻辑运算,如果确实有复杂的逻辑运行,请考虑使用方法,我们将在下一章讨论方法的方方面面。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· .NET Core 中如何实现缓存的预热?
· 从 HTTP 原因短语缺失研究 HTTP/2 和 HTTP/3 的设计差异
· AI与.NET技术实操系列:向量存储与相似性搜索在 .NET 中的实现
· 基于Microsoft.Extensions.AI核心库实现RAG应用
· Linux系列:如何用heaptrack跟踪.NET程序的非托管内存泄露
· TypeScript + Deepseek 打造卜卦网站:技术与玄学的结合
· Manus的开源复刻OpenManus初探
· .NET Core 中如何实现缓存的预热?
· 三行代码完成国际化适配,妙~啊~
· 阿里巴巴 QwQ-32B真的超越了 DeepSeek R-1吗?