C#基础(六):类的四大特性
封装、继承、多态、抽象是面向对象编程(OOP)的四大核心支柱,C# 作为纯面向对象语言,从语法和运行时层面完整支持这四大特性。它们共同支撑了代码的可复用性、可扩展性、安全性与可维护性。
一、封装(Encapsulation)
核心本质
封装是指将对象的状态数据(字段)和操作数据的行为(方法)捆绑在一起,隐藏内部实现细节,仅对外暴露必要的访问接口,通过访问权限控制保护内部状态的安全性。
简单说就是:该藏的藏起来,该露的露出来。
核心价值
- 数据安全:防止外部随意修改内部状态,避免非法值
- 降低耦合:外部只依赖公开接口,内部实现修改不影响调用方
- 简化使用:调用方无需了解内部复杂逻辑,只需调用公开接口
- 可维护性:内部逻辑可自由重构,只要对外接口不变就不会破坏现有代码
实现手段:访问修饰符
C# 提供 6 种访问修饰符,精确控制成员的可见范围,这是实现封装的语法基础。
| 访问修饰符 | 可见范围 | 说明 |
|---|---|---|
public |
完全公开,任何地方都能访问 | 对外暴露的接口使用 |
private |
仅当前类内部可访问 | 默认访问级别,封装内部状态 |
protected |
当前类及其子类可访问 | 给子类开放权限,不对外部公开 |
internal |
同一程序集(项目)内可访问 | 程序集内部共享,对外隐藏 |
protected internal |
同一程序集 OR 子类可访问 | 是 “或” 的关系,范围最广的非公开级别 |
private protected |
同一程序集内的子类才可访问 | 是 “且” 的关系,范围最严格的保护级别 |
注意:类 / 结构体默认访问级别为
internal,类成员默认访问级别为private。
代码示例:从 “无封装” 到 “良好封装”
❌ 未封装的反面案例:public 字段
public class Person { // 直接公开字段,外部可随意修改,完全无法控制 public int Age; public string Name; } // 外部可以随意写入非法值 Person p = new Person(); p.Age = -100; // 年龄为负数,完全不合理,但不会有任何校验 p.Name = null;
问题:完全暴露内部状态,无法做合法性校验,后续修改字段类型或逻辑会影响所有调用方。
✅ 良好封装:私有字段 + 公开属性
public class Person { // 字段私有化,外部无法直接访问 private int _age; private string _name; // 公开属性作为访问入口,内置校验逻辑 public int Age { get => _age; set { // 封装校验逻辑,保证数据合法性 if (value < 0 || value > 150) throw new ArgumentOutOfRangeException(nameof(value), "年龄必须在 0-150 之间"); _age = value; } } public string Name { get => _name; set { if (string.IsNullOrWhiteSpace(value)) throw new ArgumentException("姓名不能为空"); _name = value; } } // 公开方法作为行为接口,内部实现可自由修改 public void GrowOlder() { _age++; // 内部可以做更多复杂逻辑,外部无感知 } }
优势:外部只能通过属性和方法交互,内部校验逻辑、存储方式可以随时重构,只要接口不变就不会影响调用方。
封装的粒度控制
- 完全隐藏:private 字段、私有辅助方法,仅内部使用
- 只读暴露:只有 get 没有 set,外部只能读取不能修改
- 只写暴露:只有 set 没有 get(极少使用)
- 分级开放:protected 给子类开放,internal 给同程序集开放
最佳实践与坑点
- 字段一律私有:99% 的场景不要声明 public 字段,统一用属性对外暴露
- 最小可见原则:成员能 private 就 private,能 internal 就不 public
- 不要过度封装:简单的数据传输对象(DTO/POCO)可以使用自动属性,不必强行写复杂校验
- 属性本质是方法:属性内不要写耗时逻辑,避免调用方误以为是简单字段访问
二、继承(Inheritance)
核心本质
继承是基于现有类(基类 / 父类)创建新类(派生类 / 子类)的机制,子类可以复用父类的字段、属性和方法,同时可以扩展自己的新功能,实现 is-a 的关系。
继承的核心价值是代码复用与层次化分类。
C# 继承核心规则
- 单继承:一个类只能有一个直接父类,不支持多继承(多继承通过接口实现)
- 传递性:继承具有传递性,C 继承 B,B 继承 A → C 间接拥有 A 的所有可访问成员
- 万物皆 object:所有类最终都隐式继承自
System.Object,因此所有对象都拥有Equals()、GetHashCode()、ToString()、GetType()四个基础方法 - 派生类扩展不影响基类:子类新增成员不会改变父类,也不能删除父类成员
- 构造函数不可继承:父类的构造函数不会被子类继承,子类必须定义自己的构造函数,但可以通过
base调用父类构造
基础语法与关键字
// 基类(父类) public class Animal { public string Name { get; set; } public int Age { get; set; } public void Eat() { Console.WriteLine($"{Name} 在吃东西"); } // 虚方法,留待子类重写(多态部分详解) public virtual void MakeSound() { Console.WriteLine("动物发出叫声"); } } // 派生类(子类):使用 : 继承父类 public class Dog : Animal { // 子类扩展自己的成员 public string Breed { get; set; } public void Run() { Console.WriteLine($"{Name} 在奔跑"); } // 重写父类虚方法 public override void MakeSound() { Console.WriteLine($"{Name} 汪汪叫"); } } // 使用 Dog dog = new Dog(); dog.Name = "旺财"; dog.Eat(); // 复用父类方法 dog.MakeSound();// 调用子类重写后的方法 dog.Run(); // 子类自有方法
base 关键字:访问基类成员
base 用于在子类中显式访问父类的成员,主要有两个用途:
- 调用父类的构造函数
- 调用父类被重写的方法
public class Dog : Animal { public string Breed { get; set; } // 1. 调用父类构造函数 public Dog(string name, int age, string breed) : base(name, age) { Breed = breed; } public override void MakeSound() { // 2. 先调用父类的实现,再扩展子类逻辑 base.MakeSound(); Console.WriteLine("汪汪汪!"); } }
构造函数执行顺序
继承场景下构造函数的执行顺序是高频考点,遵循「静态先子类后父类,实例先父类后子类」的原则:
- 执行子类静态构造函数
- 执行父类静态构造函数
- 子类实例字段初始化
- 执行父类实例构造函数
- 执行子类实例构造函数体
public class Base { public Base() { Console.WriteLine("父类实例构造"); } static Base() { Console.WriteLine("父类静态构造"); } } public class Derived : Base { public Derived() { Console.WriteLine("子类实例构造"); } static Derived() { Console.WriteLine("子类静态构造"); } } // 首次 new Derived() 输出顺序: // 子类静态构造 // 父类静态构造 // 父类实例构造 // 子类实例构造
protected 访问修饰符
专门为继承设计,成员在父类和子类中可访问,对外部不可见,是封装与继承的平衡。
public class Animal { protected string _internalState; // 受保护字段 } public class Cat : Animal { public void Test() { _internalState = "正常"; // 子类内部可以访问 } } // 外部代码无法访问 _internalState
方法隐藏:new 关键字
子类可以声明与父类同名的方法,默认会有编译器警告;使用 new 关键字可以显式隐藏父类方法,注意这不是多态。
密封类与密封方法:sealed
sealed class:禁止被其他类继承,如string、int对应的运行时类都是密封类sealed override:密封重写方法,禁止子类继续重写该虚方法
// 密封类:无法被继承 public sealed class FinalClass { } // 密封方法:重写后禁止子类继续重写 public class Child : Parent { public sealed override void VirtualMethod() { base.VirtualMethod(); } }
里氏替换原则(LSP)
面向对象设计七大原则之一,核心含义:所有使用父类的地方,都可以透明地替换为子类,而不会产生逻辑错误。
- 子类必须实现父类的所有契约
- 子类不能改变父类方法的输入输出约定
- 子类只能扩展功能,不能缩小父类的能力
里氏替换原则是继承正确使用的指导原则,也是多态能够正常工作的前提。
最佳实践与坑点
- 优先组合,慎用继承:继承是强耦合,父类修改会影响所有子类;代码复用优先考虑组合而非继承
- 继承层次不要过深:一般不超过 3 层,过深的继承会导致理解困难、维护成本高
- 不要为了复用而强行继承:没有 is-a 关系不要滥用继承,比如 “工具类继承数据类”
- 父类显式声明无参构造:如果父类只有有参构造,子类必须显式调用
base(参数),否则编译报错 - 不要滥用 protected:protected 成员也是公开接口的一部分,修改会影响所有子类
三、多态(Polymorphism)
核心本质
多态字面意思是 “多种形态”,指同一个操作作用于不同的对象,可以产生完全不同的执行结果。简单说就是:一个接口,多种实现。
多态是面向对象最核心的特性,它让程序可以面向抽象 / 父类编程,而不用关心具体实现,极大提升了代码的扩展性。
分类:编译时多态 vs 运行时多态
1. 编译时多态(静态多态)
在编译阶段就确定了调用哪个版本的方法,通过方法重载、运算符重载实现。
方法重载
同一个类中,方法名相同,但参数列表不同(参数类型、个数、顺序至少有一个不同),与返回值无关。
public class Calculator { // 重载1:两个int public int Add(int a, int b) => a + b; // 重载2:三个int public int Add(int a, int b, int c) => a + b + c; // 重载3:两个double public double Add(double a, double b) => a + b; } // 编译时根据参数自动匹配对应方法 Calculator calc = new Calculator(); calc.Add(1, 2); // 调用重载1 calc.Add(1.5, 2.5); // 调用重载3
特点:调用哪个版本在编译时就已确定,性能高,灵活性低。
2. 运行时多态(动态多态)
程序运行时,根据对象的实际类型动态决定调用哪个方法,通过 virtual / override 实现,这是真正意义上的多态。
核心关键字
virtual:修饰父类方法,表示该方法是虚方法,子类可以重写override:修饰子类方法,表示重写父类的虚方法
// 基类 public class Shape { // 虚方法:提供默认实现,子类可重写 public virtual double GetArea() { return 0; } } // 子类1:圆形 public class Circle : Shape { public double Radius { get; set; } // 重写虚方法,提供圆形自己的实现 public override double GetArea() { return Math.PI * Radius * Radius; } } // 子类2:矩形 public class Rectangle : Shape { public double Width { get; set; } public double Height { get; set; } public override double GetArea() { return Width * Height; } } // 多态演示 // 父类引用可以指向任意子类对象 Shape shape1 = new Circle { Radius = 5 }; Shape shape2 = new Rectangle { Width = 4, Height = 3 }; Console.WriteLine(shape1.GetArea()); // 78.54,调用Circle的实现 Console.WriteLine(shape2.GetArea()); // 12,调用Rectangle的实现
核心特点:变量声明为父类类型,但实际调用的是对象真实类型的方法,这就是多态的精髓。
底层原理:虚方法表(vtable)
运行时多态的底层依靠虚方法表(Virtual Method Table, vtable) 实现:
- 每个包含虚方法的类,都会生成一张虚方法表,存储该类所有虚方法的实际地址
- 每个对象实例都有一个隐藏的「类型对象指针」,指向自己所属类的 vtable
- 调用虚方法时,运行时通过对象的类型指针找到对应 vtable,查找方法地址并执行
- 子类重写了方法,vtable 中对应位置就会替换为子类方法的地址
这就是为什么父类引用指向子类对象时,会调用子类方法 —— 因为对象的实际类型决定了 vtable。
核心对比:override 重写 vs new 隐藏
这是面试高频考点,也是最容易混淆的知识点,二者行为完全不同:
public class Base { public virtual void VirtualMethod() => Console.WriteLine("父类虚方法"); public void NormalMethod() => Console.WriteLine("父类普通方法"); } public class Derived : Base { // 重写虚方法 public override void VirtualMethod() => Console.WriteLine("子类重写方法"); // 隐藏普通方法 public new void NormalMethod() => Console.WriteLine("子类隐藏方法"); } // 测试 Base obj = new Derived(); // 父类引用,指向子类对象 obj.VirtualMethod(); // 输出:子类重写方法(多态,按实际类型调用) obj.NormalMethod(); // 输出:父类普通方法(无多态,按声明类型调用)
多态的核心价值
- 极强的扩展性:新增子类时,调用父类的代码完全不需要修改,符合开闭原则
- 面向抽象编程:上层业务只依赖抽象父类 / 接口,不依赖具体实现,降低耦合
- 统一接口,多种实现:对外提供统一的调用方式,内部实现可自由替换
最佳实践与坑点
- 需要扩展的方法才标记 virtual:不要把所有方法都设为虚方法,会有轻微性能损耗,且破坏封装
- 重写方法时保持语义一致:子类重写不能改变方法的约定,违反里氏替换原则
- 不要在构造函数中调用虚方法:父类构造执行时,子类字段还未初始化,可能导致空引用等异常
- 重写时调用 base 不是必须的:根据业务决定是否保留父类逻辑,不要盲目加
base.Method()
四、抽象(Abstraction)
核心本质
抽象是指提取事物的本质特征与核心行为,隐藏非必要的实现细节,只对外展示关键契约。抽象关注 “能做什么”,而不关心 “怎么做”。
封装是隐藏内部状态,抽象是隐藏实现细节,二者相辅相成。C# 中实现抽象的两种主要方式:抽象类和接口。
接口会在后面单独详解,本节重点讲解抽象类与抽象方法。
抽象类(abstract class)
用 abstract 修饰的类,是一种不完整的类,只能作为基类被继承,不能直接实例化。抽象类可以包含抽象成员(无实现)和普通成员(有实现)。
核心特点
- 不能实例化:不能直接
new抽象类,只能创建其子类的实例 - 可以包含抽象成员:抽象方法、抽象属性,只有声明没有实现
- 可以包含普通成员:字段、属性、方法、构造函数都可以有实现
- 子类必须实现所有抽象成员:除非子类也是抽象类
- 不能是密封类:抽象类天生就是用来被继承的,不能用 sealed 修饰
抽象方法
用 abstract 修饰的方法,只有方法签名,没有方法体,强制子类必须重写实现。
- 抽象方法默认就是 virtual 的,不能再加 virtual
- 抽象方法只能存在于抽象类中
- 子类必须用
override实现所有抽象方法
// 抽象基类:图形 public abstract class Shape { // 抽象属性:无实现,子类必须重写 public abstract string Name { get; } // 抽象方法:计算面积,只有签名,没有实现 public abstract double GetArea(); // 普通方法:有默认实现,子类可直接复用 public void PrintInfo() { Console.WriteLine($"图形:{Name},面积:{GetArea():F2}"); } } // 子类1:圆形 public class Circle : Shape { public double Radius { get; set; } // 必须实现所有抽象成员 public override string Name => "圆形"; public override double GetArea() { return Math.PI * Radius * Radius; } } // 子类2:矩形 public class Rectangle : Shape { public double Width { get; set; } public double Height { get; set; } public override string Name => "矩形"; public override double GetArea() { return Width * Height; } } // 使用 Shape circle = new Circle { Radius = 5 }; Shape rect = new Rectangle { Width = 4, Height = 3 }; circle.PrintInfo(); // 输出:图形:圆形,面积:78.54 rect.PrintInfo(); // 输出:图形:矩形,面积:12.00
抽象类的设计思想:把所有子类共有的逻辑提取到基类实现,把每个子类不同的部分声明为抽象成员,强制子类各自实现,既复用了代码,又定义了统一契约。
抽象方法 vs 虚方法
| 对比维度 | 抽象方法 abstract | 虚方法 virtual |
|---|---|---|
| 实现体 | 没有实现体 | 有默认实现体 |
| 所在类 | 只能在抽象类中 | 可以在普通类、抽象类中 |
| 子类要求 | 必须重写 | 可以选择重写,也可以不重写 |
| 多态性 | 天生支持多态 | 支持多态 |
| 作用 | 强制子类实现契约 | 提供默认实现,允许子类扩展 |
抽象类 vs 普通类
| 对比维度 | 抽象类 | 普通类 |
|---|---|---|
| 实例化 | 不能实例化 | 可以实例化 |
| 抽象成员 | 可以包含 | 不能包含 |
| 继承 | 只能作为基类 | 可以被继承也可以独立使用 |
| 作用 | 定义统一契约 + 复用公共代码 | 完整的功能实现 |
抽象的核心价值
- 定义统一契约:强制所有子类遵循相同的接口规范,保证一致性
- 分离关注点:上层业务只依赖抽象,不用关心具体实现细节
- 支撑多态:抽象是多态的基础,没有抽象就没有真正的多态
- 代码复用:公共逻辑放在抽象基类,避免子类重复编写
最佳实践与坑点
- 抽象类命名建议加 Base 后缀:如
ShapeBase、ControllerBase,清晰表明是抽象基类 - 抽象类不要包含太多公共逻辑:避免基类过于臃肿,违反单一职责
- 行为契约优先用接口:如果只是定义行为规范,没有公共代码需要复用,优先使用接口而非抽象类
- 不要为了抽象而抽象:不需要多扩展的场景不要强行引入抽象层,增加不必要的复杂度
四大特性总结
- 封装是基础:藏起细节,保护数据,对外提供安全的访问入口
- 继承是手段:复用代码,构建层次体系,实现 is-a 关系
- 多态是核心:一个接口多种实现,让程序灵活可扩展
- 抽象是灵魂:提取本质,定义契约,面向接口编程
四者不是孤立的:封装是前提,继承是载体,抽象是指导,多态是结果,共同构成了面向对象编程的完整体系。

浙公网安备 33010602011771号