☰
C#面向对象实战:掌握封装继承多态,搞定类与对象设计
2026/10/1 13:43:47 网站建设 项目流程

身边学C#的朋友经常问我一个问题:“面向对象到底是个啥?我看了好多教程,封装继承多态背得滚瓜烂熟,但写项目时还是用不上,感觉像是武林秘籍上的招式,一到实战就全忘了。”这个问题我太有感触了。我刚开始学C#时也一样,类、对象、属性、方法这些名词每个字都认识,连在一起就完全不知道在说什么。直到后来写了几个真正的小项目,才慢慢摸到门道。

这篇博文就是写给当初那个迷茫的自己,也写给所有卡在C#面向对象门槛上的读者。我会用最直白的语言、最贴近实际开发的代码场景,把封装、继承、多态这三个核心概念彻底讲透。你不需要有任何面向对象基础,只要会用C#写过几行控制台程序就行。看完之后你会发现,面向对象不是玄学,它就是一套组织代码、管理复杂度的思维工具,而C#把这种工具打磨得相当顺手。

1. 先搞清楚:面向对象到底在解决什么问题

1.1 一个场景带你入坑:管理诊所里的宠物

假设你要写一个宠物诊所的管理程序。现在诊所里有三只动物:一只狗、一只猫、一只鸟。你需要记录它们的名字、年龄、健康状况,并且能输出它们各自的叫声。如果按面向过程的方式,你大概会写一堆变量加一堆方法:

// 面向过程风格 string dogName = "旺财"; int dogAge = 3; string dogHealth = "健康"; string catName = "咪咪"; int catAge = 5; string catHealth = "感冒"; void DogBark() => Console.WriteLine("汪汪!"); void CatMeow() => Console.WriteLine("喵喵!");

这种写法有两个问题马上就暴露了。第一,变量一多,你根本分不清哪些变量属于哪只动物,名字和年龄的关系全靠命名规范硬撑,一旦代码超过几百行,维护起来就是灾难。第二,当你需要给每只动物加一个“打疫苗”的操作时,就得写 DogVaccine、CatVaccine、BirdVaccine 三个方法,它们的逻辑基本一样,只是作用在不同数据上,代码被迫重复。

面向对象解决的就是这两个问题:把数据和操作数据的方法绑定在一起,形成一个独立的单元,这个单元就是“对象”。狗,不仅有自己的名字、年龄,还知道怎么叫、怎么吃、怎么打疫苗。数据和行为不再分裂,而是作为一个整体存在,大脑不必去记忆散落的全局变量,只需要关心对象本身。

1.2 类和对象不是玄学,是图纸和实物

很多初学者分不清“类”和“对象”,其实用生活里的东西一比就清楚了。类是图纸,对象是照着图纸建出来的房子;类是这个世界的概念,对象是真实存在的具体个体。

你看上面那张示意图,Dog是一个类,它描述的是“所有狗都具有的属性和行为”;而旺财是Dog类的一个对象,是这个图纸之下的一个具体实例。你不可能在小区里遇到一只“狗类”,你能遇到的只有“那只叫旺财的黄狗”。在C#里,new关键字干的事就是盖房子:从类这个图纸创建出一个实实在在的对象。

Dog dog = new Dog(); // 照着 Dog 图纸,造一个具体的狗对象 dog.Name = "旺财"; dog.Bark(); // 让这个对象执行叫这个行为

从这一刻起,你写代码的思路就变了:不再纠结“我该怎么操作这份数据”,而是“这个对象提供哪些能力、我该怎么跟它协作”。

1.3 面向对象三大特性:一句话版先行

封装、继承、多态这三个词,第一次见面很吓人,但你只需要记住三句人话:

  • 封装:把数据和操作数据的细节藏起来,只留几个按钮给你按。
  • 继承:子类自动拥有父类的一切(非私有成员),相当于站在父辈的肩膀上写代码。
  • 多态:同一句代码,作用于不同对象时,表现出不同的行为。

接下来,我们就拿宠物诊所这个场景,把这三句话逐个展开,配合代码看它到底是怎么落地的。

2. 封装:把数据装进保险箱,对外只留按钮

2.1 为什么不能把所有字段都设成 public

新手最容易犯的错,就是把字段全都写成 public,图方便,认为“我想怎么改就怎么改”。但这种方便是有代价的。举个例子,诊所里宠物年龄字段,正常范围肯定是 0 到 30 岁之间,可如果你把Age暴露成公开字段,谁也拦不住某段代码写出pet.Age = -5。这行代码不会报错,但它会污染整个系统的数据。

数据的完整性是程序的底线。一旦某个数据被赋予非法值,后面所有基于它的计算都可能悄悄出错,而那个 bug 藏得极深,你排查几个小时也未必能定位到源头。封装的第一层意义,就是给字段加上守卫,让外部无法直接篡改内部状态。

把Age改成私有字段,再通过属性对外提供受控的访问入口,破坏数据完整性的入口就被堵死了:

private int _age; public int Age { get { return _age; } set { if (value < 0 || value > 30) { throw new ArgumentOutOfRangeException(nameof(value), "年龄必须在0到30之间"); } _age = value; } }

现在如果有人再写pet.Age = -5,系统会当场抛异常,告诉你“非法值,不允许写入”。这就是封装的直接价值。

2.2 字段、属性、方法的配合

在C#里,类内部最基本的三个成员是字段、属性、方法。它们的职责分工是:字段保存状态,属性作为访问状态的安检门,方法表达对象的行为。

我们把“宠物”这个基类型抽象出来,名字就叫Pet。它既有数据(名字、年龄、健康状态),也有行为(吃饭、叫唤):

public class Pet { // 私有字段:外部永远碰不到,这是保险箱内部 private int _age; private string _healthLevel = "健康"; // 自动属性:直接用,不用手动写私有字段 public string Name { get; set; } // 带校验的属性:set访问器里做逻辑拦截 public int Age { get => _age; set { _age = value < 0 ? 0 : value; } } // 构造函数:创建对象时把基础状态初始化好 public Pet(string name, int age) { Name = name; Age = age; } // 行为方法 public void Eat() { Console.WriteLine($"{Name}正在吃饭,健康值小幅提升。"); _healthLevel = "良好"; } public virtual void Speak() { Console.WriteLine($"{Name}发出叫声。"); } }

这里有一个很多人容易忽略的细节:属性并不是简单的字段替代品,它本质上是一对 get/set 方法。编译器在背后帮你把属性编译成了get_Age()和set_Age(value)两个方法。所以你在属性里写任何逻辑(校验、触发事件、计算后再返回)都是合理的,这就是封装允许你做文章的地方。

另外注意Name用的是自动属性。自动属性适合那种“无额外逻辑”的简单字段,它帮你省去手动声明私有字段的代码,但本质没变,背后还是有一个隐藏字段。我见过不少新手把自动属性当成 public 字段来用,不考虑业务约束,这样不行——到了需要加校验的时候,再改回完整属性可能会影响所有调用方,所以建议一开始就思考清楚。

2.3 访问修饰符到底怎么选

C# 提供了一组访问修饰符,初学者往往记不清。我把常用情况列个表:

修饰符可访问范围什么时候用
public对所有类公开类型自身的公开 API,门面
private仅当前类内部内部字段、内部辅助方法,默认首选
protected当前类及派生类需要让子类访问,但不想对全世界公开
internal同程序集内公开同一项目内部共享,不对外暴露

这个表只要理解记忆就可以了。我的习惯是:字段一律 private,能 private 就 private;对外提供访问能力用属性;需要子类扩展的方法才用 protected virtual。这个习惯能让你的类像一个密封性良好的盒子,内部怎么折腾都行,外部永远只通过受控入口交互。

在实际维护老项目时你会发现,早期图省事写的一堆 public 字段,后期封装的成本远高于一开始就写对。这是封装经验里最重要的一句话。

2.4 构造函数:给对象一个合理的初始状态

对象从new出来的那一刻,就应该处于一个完整、可用的状态,而不是让其他代码去 “装修”。构造函数就是干这个的。上面Pet类里已经定义了一个构造函数Pet(string name, int age),这样在创建对象时就必须提供名字和年龄,避免了 “一个宠物没有名字” 这种非法状态出现。

如果类里没有任何构造函数,编译器会补一个无参构造。一旦你自己写了构造函数,编译器就不自动补了。所以派生类里怎么写构造函数,是个需要专门注意的点,这个我们在继承部分接着聊。

另外,C# 里还有一种常见的写法叫“对象初始化器”,它是在构造函数基础上提供更灵活的赋值方式:

Pet pet = new Pet("旺财", 3) { /* 这里可以继续初始化其他公开属性 */ };

它的本质是先调构造函数,再对可写属性依次赋值。灵活,但不要把核心的必填项省掉放这里,因为调配顺序不在你控制内,核心字段还是交给构造函数更稳妥。

3. 继承:站在父辈的肩膀上写代码

3.1 用一个类体系的示意图理解继承

现在诊所又进了几只新的宠物:狗、猫、鸟。它们各不相同,但都有名字、年龄、健康状态,都需要吃饭、叫唤。如果每个类都从零写一遍这些公共成员,你会疯掉。继承机制就是为这种 “有一批共性又有差异” 的类型体系设计的。

Pet(基类,抽象出共性) / | \ Dog Cat Bird / \ Teddy Husky

在这个体系里,Pet是基类(父类),Dog、Cat、Bird是派生类(子类)。子类自动拥有父类的非私有成员,同时可以添加自己的独特成员,也可以改变父类已有方法的行为。Teddy和Husky是Dog的再下一层,体现的是更精细的差异。

继承解决的核心问题是复用共性、扩展差异。它是一种表达 “is-a”(是一个)关系的机制:狗是一种宠物,所以狗天然拥有宠物的一切特征。但反过来不能说宠物是一只狗,所以继承的方向是不可逆的。

3.2 子类如何写:派生类的完整定义

在 C# 中定义派生类,语法就是在类名后面冒号写上基类名。继承Pet后,Dog类自动就有了Name、Age、Eat(),你只需要关注 “狗” 自己独有的东西:

public class Dog : Pet { // 狗独有的字段 public string Breed { get; set; } // 品种 // 构造函数:必须调用基类构造函数 public Dog(string name, int age, string breed) : base(name, age) { Breed = breed; } // 狗独有的行为 public void FetchBall() { Console.WriteLine($"{Name}飞奔出去捡球。"); } }

注意构造函数这行代码,base(name, age)是调用基类Pet的构造函数,把名字和年龄的处理责任上交给基类。这是初学者最容易漏的:如果基类有自定义构造函数,派生类构造函数必须主动通过base调用,否则编译会报错。因为编译器不知道你想怎么初始化父类的部分。

3.3 base关键字:不只是构造函数

base关键字在类内部还有两个常见用途。一是调用父类方法,主要用于你想 “在父类行为基础上增加额外逻辑” 的时候。比如所有宠物吃饭都要体现“健康值提升”这个公共逻辑,但狗吃完饭还想额外输出一句“真香”,可以这样写:

public class Dog : Pet { public override void Eat() // 先说明:这个是重写,下一章会细讲 { base.Eat(); // 先执行父类的 Eat 逻辑 Console.WriteLine($"{Name}舔了舔碗,真香!"); } }

这里base.Eat()先执行了父类中定义好的公共逻辑,再加上狗自己的特殊表现。这个模式在实际项目中非常常见,叫 “模板方法” 的雏形:父类定义骨架,子类在骨架基础上做扩展。

有一种情况要注意:如果你没有 override 的需求,只是想在子类里调用父类一个普通方法,直接base.方法名()就行。但别滥用base去访问父类私有成员——私有成员压根访问不到,这是封装决定的。

3.4 继承正确的理解方式:is-a,而不是 has-a

很多教程讲继承只讲了语法,没讲什么时候应该继承。这里给你一个判断标准:子类必须真的是一种父类。狗是一种宠物,所以狗继承宠物没问题;但“名字”不是一个宠物,所以名字不能去继承宠物,它应该是宠物的一个属性(has-a)。同样,“打印机”类里包含“墨盒”对象,这是 has-a,不该让打印机继承墨盒。

不少人一看到 “代码可以复用” 就疯狂用继承,结果造出一堆别扭的父子关系,比如让Manager继承Employee(这个其实还行),又让Mechanic继承Car(这个就错了,机械师不是车)。继承层级一旦设计错误,后面改动父类一个方法,全体系崩溃。建模时多问一句 “这真的是 is-a 关系吗”,能帮你避掉大部分坑。

4. 多态:同一句代码,跑出不同行为

4.1 先认识两种“多态”:重载和重写

“多态”这个词被翻译得很学术,实际意思特别朴素:同一个方法名,在不同场景下有不同表现。C# 里主要有两种:

  • 重载(Overload):同一个类里,方法名相同,参数列表不同,编译时就确定调用哪个版本。
  • 重写(Override):子类重写父类的虚方法,同一个调用点代码,运行时才根据实际对象类型决定执行哪个版本。

重载是编译时多态,重写是运行时多态。初学者刚开始不用死抠这两个概念,但代码一定要认得出。我们先看重载:

public class PetClinic { public void Examine(Pet pet) { Console.WriteLine($"为 {pet.Name} 进行常规检查。"); } public void Examine(Dog dog, bool checkBreed) { Console.WriteLine($"为 {dog.Name} 进行详细检查,重点检查品种 {dog.Breed}。"); } }

同一个“检查”动作,参数一个是Pet、一个是Dog加一个bool,调用时传什么参数就自动选哪个方法。这是 C# 的静态分发机制,简单直接。

4.2 重写:父类留坑,子类填坑

重写建立在两个关键字上:父类用virtual标记一个可以被子类改写的虚方法,子类用override标记这个方法是重写父类的。回到 Pet 类,我们已经在Speak()上加了virtual,现在三个子类各自重写:

public class Dog : Pet { public override void Speak() { Console.WriteLine($"{Name}汪汪叫。"); } } public class Cat : Pet { public override void Speak() { Console.WriteLine($"{Name}喵喵叫。"); } } public class Bird : Pet { public override void Speak() { Console.WriteLine($"{Name}叽叽喳喳叫。"); } }

现在神奇的事情来了:你可以把所有宠物装进一个Pet类型的集合,遍历时调Speak(),代码只需要写一遍,运行时却会自动调用每只动物自己的叫声版本:

List<Pet> pets = new List<Pet> { new Dog("旺财", 3, "金毛"), new Cat("咪咪", 5, "橘猫"), new Bird("小翠", 1, "鹦鹉") }; foreach (Pet pet in pets) { pet.Speak(); }

输出结果:

旺财汪汪叫。 咪咪喵喵叫。 小翠叽叽喳喳叫。

你能看到,List里存的是Pet类型,foreach声明也是Pet pet,但pet.Speak()这句同一个代码,对不同对象产生了不同行为。这就是运行时多态的核心场景。它让代码面向基类编程,而不是面向具体子类编程,扩展性大增:以后诊所再来一只仓鼠,只需写一个Hamster : Pet,重写Speak(),上面这段遍历代码一行都不用改。

4.3 抽象类和抽象方法:强制子类必须实现

直接继承父类时,父类的方法可以有默认实现(比如原来的Pet.Speak()),子类不想覆盖也行。但有些场景下,父类说 “我实在不知道该怎么统一叫,你有自己的方式”,这时就该用抽象方法了。把Pet改成抽象类,Speak()改成抽象方法:

public abstract class Pet { public string Name { get; set; } public int Age { get; set; } public Pet(string name, int age) { Name = name; Age = age; } public void Eat() { Console.WriteLine($"{Name}正在吃饭。"); } // 抽象方法:没有方法体,子类必须重写 public abstract void Speak(); }

抽象方法的特点是没有方法体,它就像父类在图纸上画了一个带重写标记的空坑:子类必须把这个坑填上,否则子类自己也会变成抽象类,无法实例化。这种强制是好事,它把“每种动物都应该能叫”这个契约固定下来,防止有人写了子类却忘了实现叫的方法。

把Pet标记为abstract后,你就不能直接new Pet()了,因为世上不存在一只泛泛的 “宠物”,它必须是某一种具体动物。这也符合作业逻辑:诊所里不会进来一个 “Pet 类型” 的患者,进来的都是狗猫鸟等具体的宠物。

4.4 接口:不只是继承类,更是实现契约

当多个不相干的类型需要具备同一批能力时,光靠继承就不够用了。比如诊所里不仅能接收宠物,还可能接收 “主人寄养寄存的箱子”(是的,你没看错,有些诊所代管快递箱),快递箱和宠物没有 is-a 关系,但它们都需要登记入库、取出。这时候就需要一个契约,让不同类型都 “保证提供某个能力”。

public interface IStorable { void Store(string location); void Retrieve(); }

接口在小处看就是一组方法签名,更像是招聘启事:只要你会 “存取” 这个技能,你就能当仓库管存的物品。让Pet和Box都实现这个接口,就不需要人为去建立继承关系:

public class Box : IStorable { public void Store(string location) { Console.WriteLine($"快递箱已存放到 {location} "); } public void Retrieve() { Console.WriteLine("快递箱已取出。"); } }

接口和抽象类常被拿来对比,我给你列一个最直观的对照:

对比项抽象类接口
关键字abstract classinterface
是否有字段可以C# 8 之前不行,一般不推荐
是否有实现可以有具体方法可以有默认实现(默认接口方法,旧版无)
一个类型可继承/实现几个只能继承一个基类可以实现多个接口
表达关系is-a(是一个)can-do(能做某件事)

实战建议是:优先考虑接口。它比继承更灵活,能避免 Java/C# 单继承模型带来的限制,也让系统各部分解耦。等你发现多个类除了实现同一接口外,还有大量公共代码,才去考虑抽象类。

5. 初学者最容易踩的坑(过来人经验)

5.1 把字段直接 public,图省事,结果改出 bug

写小 demo 时谁都会图快,字段直接 public,但项目一复杂就完蛋。我见过一个同事,把一个IsActive字段直接公开,结果多个模块都在改它,有人先置false判断后置true,业务逻辑被冲得乱七八糟,bug 查了整整两天。最后排查到是某个功能忘记置回true,而它碰巧写在了另一个模块的分支里。

补救办法就是一开始就养成 “字段 private,对外属性” 的习惯。哪怕是自动属性,也比 public 字段强,因为属性能在未来加校验而不破坏调用方。

5.2 构造函数里调用虚方法,踩坑了

C# 在构造对象时,如果基类构造函数里调用了虚方法,而这个虚方法被子类重写了,执行顺序会先去执行子类的重写版本,但此时子类自己的字段还没完全初始化,很容易得到空引用或默认值。这个坑连很多老手都会偶发。规避方式很简单:构造函数里不要调用虚方法。如果确实需要做一些初始化,把那些逻辑拆到子类可以覆写的专用初始化方法中,并在合适时机调用。

5.3 继承层级过深,改一处崩一片

继承确实复用代码,但层级别超过三层。第二层可能还有几十个子类,第三层之后每次改父类方法,都要政治审计所有后代类。实战中三层是比较舒服的上限:基类抽公共、中间层细化分类、最外层是具体实现类。如果再往下就需要拆接口或者组合了。

组合优于继承这个设计原则记住即可:如果用 has-a 能解决的问题,就不要拉出 is-a 来硬凑。例如需要给宠物增加 “远程定位” 功能,与其让所有宠物继承一个GpsAnimal类,不如让宠物类内部组合一个GpsService对象。

5.4 纠结抽象类和接口,不知道怎么选

大多数新手纠结的是这两个,我给一个傻瓜判断法:多个类之间是is-a关系且需要共享实现代码,就用抽象类;它们只是都具备某个能力,没有公共代码要共享,就用接口。还有一种常见工程实践:定义接口做系统边界,再用抽象类做接口的默认实现,把常用逻辑沉淀在抽象类里,两边优势都能吃到。

5.5 类和结构体,选错了也坑

C# 里,class是引用类型,struct是值类型。做业务建模时优先用类;只有当你明确对象是轻量、不可变、经常批量创建(比如坐标点、颜色值、一条金额记录)时,才考虑结构体。把实体模型错选成结构体,会在传递、装箱、拷贝上出现一系列性能与引用语义问题,对新手来说属于前期不好感知、后期非常头大的坑。一句口诀:业务模型用类,纯数据快照用结构体。

其实踩坑比顺风顺水更能建立对面向对象的体感。我不建议一上来就啃设计模式,先把封装、继承、多态这三个基础招式练到不用想就能写出来,再去研究 SOLID、工厂、策略那些高阶东西,你才会发现它们全都建立在今天这些基础之上。写代码没有顿悟,只有反反复复的练习和一坑一坑地踩,踩完回头看,原来面向对象早就在那儿等着你了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询