C# 类与对象
类与对象是面向对象编程语言最重要的部分。
类的三大核心特性——封装(Encapsulation)、继承(Inheritance)和多态(Polymorphism)是面向对象编程(OOP)的基础。这些特性使得代码更具组织性、可重用性和灵活性。
类可以认为是一张图纸,而对象则是按照图纸造出来的实体。
1 | Object o = new Object(); |
Object
是类,o
是object
的实例,也就是对象。
类的定义
1 | <访问标识符> class 类名 |
定义一个类:
1 | using System; |
类中的成员一般都需要实例化拿到对象后才能够访问与使用,使用对象.成员
来访问。
下面开始一个一个介绍类的组成部分。
字段
用于存储对象的状态或数据。
字段通常是私有的(private
),和字段搭配使用,以实现封装。
1 | private int age; |
属性
用于访问和修改字段的值。
提供了对字段的受控访问。
1 | public int Age |
get
:获取该属性的值
set
:设置该属性的值
访问器
属性是提供对类的字段的访问的成员,它们通常有 get
和 set
访问器。属性可以在需要时添加额外的逻辑,比如数据校验。
1 | private int age; |
通过删除访问器或者给访问器设置访问修饰符来控制属性的读写权限,下面就是一个 对外只读 的属性。
1 | public int Age { get; private set; } |
提示
可以通过代码片段快速补全的形式来创建自动属性
在 Visio Studio 编辑器里输入prop
,按两下 tab 键,便会自动创建一个自动属性的代码片段
1 | public int MyProperty { get; set; } |
还有propfull
,propg
等自动补全,这里就不展示了,可以自己去尝试一下。
方法
之前我们已经知道了,每个语言都有自己的入口函数,而C#
的入口函数是Main
函数,而这个Main
函数就是一个方法。
函数和方法实际上表示的是同一个东西,两种说法都可以。
首先列出Main
方法,我们参考Main
方法来确定一个方法需要什么
1 | public static void main(string[] args){ |
public
:访问修饰符,主要是用来控制其范围和可见性,在 封装 中有其使用说明。
static
:静态修饰符,表示该函数不需要类进行实例化便可使用。
void
:返回值类型,void
代表无返回值
main
:方法名。
string[] args
参数,在调用方法时的括号里填入,传递给该方法。
综上,我们可以大概做出一个模板
1 | <访问修饰符> <返回值类型> <方法名>(参数列表) |
下面就再写一份完整的方法使用例子:
1 | namespace csharp基础 |
参数传递
形式参数:形式参数是指在方法定义时声明的参数。它们是方法签名的一部分,用于在调用方法时接收实际参数的值。
实际参数:实际参数是指在方法调用时传递给方法的具体值或变量。
通过数据类型那边,我们对值类型和引用类型的存储位置进行了说明。
值类型 的实际数据存储在栈上,
引用类型 的实际数据存储在托管堆上,而在栈上存放了它的引用。
参数在传递时,值类型复制并传递了自己的值,引用类型复制并传递了引用。
更具体的说明:参数在传递时,会将栈上的数据复制并传递到方法帧中。在这里,值类型被复制了实际数据,而引用类型被复制的是引用,即托管堆上实际数据的地址。
也就是说,值类型的数据传递过去后,进行各种修改只会影响被修改的副本,并不会影响原本的值。
而像对象这样的引用类型传递过去后,修改时是直接修改了实际数据,而引用(地址)没有变化,这就导致修改影响到了这个原来的对象。
param、ref、out
在C#
中,参数也有四个关键字来定义特殊的参数
params
:定义可变数量的参数,其类型必须是一维数组,且必须放在最后一个参数的位置out
:定义一个被输出的参数,传递参数的引用,方法可以修改参数的值,调用前需要初始化。ref
:定义一个存放引用的参数,传递参数的引用,方法必须赋值给参数,调用前不需要初始化。in
:传递参数的引用,方法只能读取参数的值,不能修改,调用前需要初始化。
params
关键字用于指定参数数组,使方法能够接受可变数量的参数,只能用于一个参数,并且必须是方法参数列表中的最后一个参数,参数类型本身必须是数组类型。
1 | 返回值 方法名(param 参数类型[] 参数名) |
例子:
1 | class Program |
ref
关键字用于按引用传递参数。方法可以读取和修改调用方的变量。
1 | class Program |
ref
一般用于传递值类型的地址,为了使形参也能够影响实参。
out
关键字也用于按引用传递参数,但不同于 ref
,它用于从方法返回多个值。
1 | class Program |
in
通过引用传递参数,但不允许方法修改参数的值。参数传递时被视为只读。调用方法之前,参数必须被初始化。
1 | using System; |
使用元组作为返回值
1 | public (int, string, bool) GetTuple() |
构造函数
类的 构造函数 是类的一个特殊的成员方法,当创建类的新对象时执行。构造函数的名称与类的名称完全相同,它没有任何返回类型。
在下面这行代码中,new
关键字在堆上分配并初始化内存后,便调用Person
类的构造函数Person()
,最后返回对象的引用,将其赋值给变量。
1 | Person p = new Person(); |
如果没有显式的声明构造函数,类会自动使用一个没有参数的构造函数。
如果类中显式的定义了一个构造函数,默认的空参空内容构造函数将无法被调用。构造函数是可以重载的,因此可以定义多个构造函数来提供不同的初始化方式。
下面的例子使用构造函数在对象创建时设置字段和属性的初始值。
1 | namespace csharp基础 |
我们在构造函数中用到了this
这个关键字,this
指的便是当前实例,this.Id
就是当前对象的Id
字段。
因为构造函数在创建对象时就会被调用的特性,在构造函数中可以写入各种希望在对象创建时完成的工作。
析构函数
析构函数和构造函数正好相反,构造函数是在创建对象时调用,而析构函数是在对象被垃圾回收时自动调用。
析构函数不能有参数,不能被显式调用,每个类也只能有一个,通常用于释放资源,如文件句柄、数据库连接或非托管内存。由于垃圾回收是非确定性的,所以不能保证析构函数在对象不再使用后立即执行。
1 | class Person |
其他
还有事件与索引器,待到之后再说。
封装
封装 —— 类的三大核心特性其一
封装是将对象的状态(字段)和行为(方法)绑定在一起,并隐藏对象的内部实现细节,仅通过公开的接口(属性和方法)与外界交互。这样可以防止外部代码直接访问和修改对象的内部状态,确保数据的完整性和安全性。
封装的实现
- 使用访问修饰符:通过
private
、public
、protected
等修饰符控制类成员的访问权限。 - 属性(Properties):使用属性来封装字段,提供对字段的受控访问。
访问修饰符
- public:公共访问修饰符
- private:仅本类访问
- protected: 在 包含他的类 或 从包含他的类的派生类 中访问
- internal:在同一个程序集内可以访问
- protected internal:在同一个程序集或者从包含它的派生类中访问
- private protected:在包含它的类或从包含它的类派生且在同一程序集中访问
默认访问修饰符
对于类成员(字段,方法,属性等),如果没有明确指定修饰符,默认是private
1 | class MyClass |
接口中的成员默认是public
,不允许有其他访问修饰符
1 | interface IMyInterface |
对于结构体成员,如果没有指定修饰符,默认是private
,与类成员类似。
1 | struct MyStruct |
枚举成员默认是public
,但它们的可见性实际上是由包含他们的枚举类型的访问级别决定的。
1 | enum MyEnum |
继承
继承 —— 类的三大核心特性其二
简单理解:杜鹃鸟 继承自 鸟类,杜鹃鸟有鸟类的所有特征,并且还有自己的独特特征。(就是随便举的例子,别杠,杠就是你对)
继承是通过从一个现有类(基类)派生出一个新类(派生类),使新类继承基类的属性和方法。继承促进了代码重用,并且允许创建层次化的类结构。
继承一个类会将这个类的所有非私有成员全部继承下来,因为private
修饰符的含义就是 仅限本类访问。
如果一个类没有继承任何类,那它便默认继承了Object
类,这就能说明为什么你的类没有定义如ToString()
方法,却能调用到这个方法。
继承使用 :
符号来表示继承关系,并且不支持多重继承。
1 | <访问修饰符> class <基类>{ |
例子:
1 | namespace 测试用 |
在这个例子中,Chinese 继承自 Person,Person 又继承自 Object
,因此Chinese
类的对象实例能够使用继承下来的Person
与Object
的成员如:SayHello()
、ToString()
、GetHashCode()
等
当实例化一个派生类时,它的基类也会被实例化。这是因为在创建派生类对象时,必须先初始化它的基类部分。这个过程确保了派生类对象在使用前,基类的成员已经正确初始化。
在获取派生类的对象实例时,会先调用基类的构造方法,再调用派生类的构造方法。
如果基类 有且仅有 有参的构造函数,则需要在派生类显式地去调用基类的构造函数。
如果基类 含有 空参的构造函数,在创建子类对像是会自动去调用基类的构造函数。
1 | namespace 测试用 |
输出结果
1 | Person的构造函数被调用了 |
我们注意到,显式的调用基类的构造函数时,使用到了base
这个关键字,与构造函数那边提到的this
相似,
this
关键字:用于引用当前对象的实例。常用于区分实例成员和方法参数,以及在构造函数之间进行调用。
base
关键字:用于引用基类的成员。常用于在派生类的构造函数中调用基类的构造函数,以及在派生类的方法中调用基类的方法。
在上面的代码中,base()
便是在调用父类的构造函数。
密封类
如果要防止当前类被其他类继承,可以使用sealed
关键字,它表明该类不能被派生,被sealed
修饰的类叫做密封类。
1 | public sealed class MySealedClass |
在这个例子中,MySealedClass
是一个密封类,任何尝试从它继承的行为都会导致编译错误。
多态
多态 —— 类的三大核心特性其三
多态是指同一操作在不同对象上的不同表现形式。它分为编译时多态(方法重载)和运行时多态(方法重写)。
重载
方法重载
同名方法使用不同的参数列表,进而实现同一操作在不同对象上出现不同表现形式。
参数列表不同指的是参数的数量、类型、顺序的不同。方法的重载与返回值无关。
1 | public class Person |
当然,构造函数也可以使用重载,只要参数列表不同即可。
运算符重载
也就是重新定义运算符,下面是一个简单的例子
1 | public static Box operator+ (Box b, Box c){ |
1 | Box b1 = new Box(); |
实际上,我们经常比较字符串使用的==
也是被重载过的运算符
1 | // 该代码来自:System.String |
可重载和不可重载运算符
下表描述了 C# 中运算符重载的能力:
运算符 | 描述 |
---|---|
+, -, !, ~, ++, – | 这些一元运算符只有一个操作数,且可以被重载。 |
+, -, *, /, % | 这些二元运算符带有两个操作数,且可以被重载。 |
==, !=, <, >, <=, >= | 这些比较运算符可以被重载。 |
&&, || | 这些条件逻辑运算符不能被直接重载。 |
+=, -=, *=, /=, %= | 这些赋值运算符不能被重载。 |
=, ., ?:, ->, new, is, sizeof, typeof | 这些运算符不能被重载。 |
重写
运行时多态通过继承和方法重写实现,基类中定义虚方法(virtual
),派生类可以重写该方法(override
)。
虚方法和其他普通的方法并无不同,只是标记了其可以被重写,不影响其正常使用。
1 | public class Animal |
派生类重写虚方法后,再次调用同名方法,将会调用自己重写过的同名方法。
在这个例子中,如果Cat
再次被继承,我们调用Cat
派生类的makeSound()
调用是Cat
重写过的makeSound()
方法,而且你会发现,被override
重写过的方法也可以被重写,换句话说,重写后的方法仍然是虚方法。
virtual
也可以修饰变量,也可以使用override
进行重写。
密封成员
当然,sealed
也可以用来修饰成员,用于阻止该成员被继承。
隐藏成员
使用new
关键字可以隐藏基类的中的成员,这样做可以让派生类提供与基类成员同名但不同实现的成员,使用new
隐藏成员并不是多态行为。
使用 new
隐藏基类成员时,新成员在派生类中与基类中的成员没有继承关系,而是独立的。
下面的代码展示了使用new
关键字隐藏方法和使用override
重写方法的区别。
1 | public class Animal |
上面的代码中,我们将Dog
对象与Cat
对象都分配给了Animal
变量。
但是,在使用MakeSound()
方法时,实际调用的方法与重写出现了差别
重写的myCat.MakeSound()
输出的内容为日之朝矣喵~
而使用new
隐藏的myCat.MakeSound()
输出的却是什么动静
当通过基类引用调用 MakeSound()
方法时,即使引用的是派生类实例,调用的仍然是基类的 MakeSound()
方法。
注意事项
- 与虚方法的区别:使用
new
隐藏基类成员与重写(override
)基类虚方法不同。new
隐藏的是非虚方法,编译器会发出警告以提醒开发者这不是多态行为。如果基类方法是虚方法,应该使用override
来重写它以实现多态行为。 - 访问方式:使用
new
隐藏的成员取决于引用类型。当通过基类引用调用隐藏成员时,调用的是基类的实现;通过派生类引用调用隐藏成员时,调用的是派生类的实现。
静态成员
定义静态成员时,只需要在成员前加上static
静态成员可以在类没有被实例化时,直接通过类名.静态成员
来访问,而且无法使用对象.成员
的方式去访问静态成员,因为静态成员归属于类而不是对象。并且静态成员变量只能在静态方法中被调用。
静态成员不能被实例调用,但是可以在构造函数与析构函数里调用
存储位置:
静态字段:在类加载时初始化,存在于应用程序域的静态存储区中。
静态方法:方法本身存储在代码段(code segment)中,但它们的元数据和方法表引用存在于静态存储区中。
静态构造函数:用于初始化静态成员,在类首次被引用时调用一次。
示例:
1 | namespace 测试用 |
静态类
static
也可以用来修饰类,用于声明一个静态类
- 静态类只能保存静态成员(还有常量)
- 无法使用
new
关键字来创建实例, - 静态类无法被继承
- 静态类在内存中只有一个实例,不管你引用它多少次,这些引用都指向同一个实例。
- 静态类的构造方法必须是静态的且不能有访问修饰符,并且构造方法的调用时机从实例化变成了第一次调用静态类。
- 类加载的时候,所有的静态成员就会被创建在“静态存储区”里面,一旦创建直到程序退出,才会被回收。
1 | class Program |
输出结果:
1 | 静态类Utility调用了构造函数 |
部分类
部分类允许将一个类的实现分散在多个文件中。
使用 partial
关键字可以定义一个部分类。
部分类在某些情况下非常有用,例如,当一个类非常大或由多个开发人员共同开发时,将类拆分成多个部分可以提高代码的可读性和可维护性。
1 | public partial class Person |
抽象类
抽象类是一种不能被实例化的类,用于定义一组行为(方法或属性)而不实现具体的行为。这些行为必须在派生类中实现。
抽象类通常用于创建一组相关类的基础类,提供通用的功能,并确保某些方法在派生类中被实现。
这样形容,抽象类 与 类 的关系,相当于 动物的定义 与 动物 的关系
抽象类 与 类 | 动物的定义 与 动物 |
---|---|
抽象类:用于定义一组行为(方法或属性)而不实现具体的行为 | 动物的定义:定义了动物共有的特征和行为,但没有具体的细节。 |
类:Dog类,Cat类等,有具体的行为。 | 动物:比如狗,猫,鸟等 |
特点
- 不能实例化:抽象类不能直接创建实例。
- 可以包含抽象成员:抽象方法或属性没有实现,只定义在抽象类中,必须在非抽象派生类中实现。
- 可以包含非抽象成员:抽象类可以包含已实现的方法、属性、字段等。
- 可以继承其他类:抽象类可以继承自其他类,并且可以实现接口。
- 用于定义公共行为:抽象类常用于定义一组相关类的公共行为,确保子类提供特定的功能实现。
抽象类使用 abstract
关键字定义,包含抽象方法和非抽象方法:
1 | public abstract class Animal |
派生类必须实现抽象类的所有抽象方法
1 | public class Dog : Animal |
附录
应用程序域
该内容由 ChatGPT 提供!
在 .NET 中,应用程序域(Application Domain)是一个独立的执行环境,它提供了一种隔离不同应用程序的方法。应用程序域可以看作是一个逻辑容器,包含了应用程序的所有代码、数据和资源。
应用程序域中的内存区域主要包括以下几个部分:
- 堆(Heap):
- 托管堆(Managed Heap):用于存储引用类型的对象(如类实例)。.NET 的垃圾回收器(Garbage Collector, GC)会管理托管堆,自动回收不再使用的对象。
- 大对象堆(Large Object Heap, LOH):用于存储大对象(通常大于 85,000 字节)。大对象堆是托管堆的一部分,但由于其特殊的性能特性,.NET 将其单独处理。
- 栈(Stack):
- 每个线程都有自己的栈,用于存储局部变量和方法调用的上下文。栈上的变量是值类型或对托管堆对象的引用。
- 静态存储区(Static Storage Area):
- 存储静态字段和静态方法的元数据。静态成员在类首次被加载时分配,并在应用程序域的生命周期内存在。
- 代码段(Code Segment):
- 存储已编译的代码(即 IL 代码和 JIT 编译后的本机代码)。代码段包含方法的实现,并且是只读的。
- 元数据区(Metadata Area):
- 存储类型信息(如类、结构、枚举等),方法签名,属性和事件的描述。元数据用于类型检查、反射等。
- 方法表(Method Table):
- 每个类型都有一个方法表,包含了该类型的方法的指针和一些类型信息。方法表用于方法调用的调度。
内存区域的交互
- 托管堆和垃圾回收:引用类型的对象分配在托管堆上,垃圾回收器会自动管理这些对象的生命周期。当对象不再被引用时,垃圾回收器会回收它们的内存。
- 栈和方法调用:方法调用时,局部变量和调用上下文会在栈上分配。方法返回时,相应的栈帧会被释放。
- 静态存储区和类加载:静态成员在类首次被引用时初始化,并在应用程序域的整个生命周期内存在。通过类名可以访问这些静态成员。
图示
下图展示了应用程序域内的主要内存区域及其关系:
1 | +------------------------+ |
参考内容
- 标题: C# 类与对象
- 作者: 日之朝矣
- 创建于 : 2024-07-26 16:56:52
- 更新于 : 2024-08-18 09:25:27
- 链接: https://blog.rzzy.fun/2024/07/26/csharp-classes-and-objects/
- 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。