1. 从一个椭圆类说起:为什么继承和接口是C#面向对象的试金石
很多刚接触C#面向对象编程的朋友,学到继承和接口这一块时,总会觉得概念都懂,但一到自己动手设计类结构就犯难。我当年也是这样,看别人写的代码觉得理所当然,轮到自己设计就不知道从哪下手。后来我发现,用几何图形来练手是最高效的方式之一,尤其是椭圆和圆这一对天然具备继承关系的图形,几乎能把面向对象的核心知识点全部串起来。
这个Demo的核心就是设计一个Ellipse(椭圆)类,让它继承自Circle(圆)类,同时实现IComparable接口和IComparer 接口。你可能会问,圆不就是特殊的椭圆吗,为什么是椭圆继承圆而不是反过来?这个问题恰恰是理解继承本质的关键。从数学定义上讲,圆是长短半轴相等的椭圆,所以圆是椭圆的一个特例。但在面向对象设计中,继承关系的确立要看“is-a”关系是否成立,以及基类是否提供了子类可以复用的核心行为。这个Demo选择让椭圆继承圆,更多是从代码复用的角度出发——圆类已经封装了半径、面积计算、周长计算等基础能力,椭圆类可以在此基础上扩展出长短半轴的概念。
这个Demo适合谁呢?如果你正在学习C#的面向对象编程,已经了解了类、属性、方法的基本写法,但对继承体系中构造函数怎么传参、接口到底该怎么实现、泛型接口和非泛型接口有什么区别这些问题还不太清楚,那这个Demo就是为你准备的。我会把每一步的设计思路、代码实现、踩坑经验都讲清楚,你跟着做一遍,对C#面向对象的理解会上一个大台阶。
整个Demo涉及的知识点包括:类的继承与构造函数链、虚方法重写、接口契约的实现、泛型接口与非泛型接口的配合使用、IComparable和IComparer 的区别与适用场景、以及集合排序的实际应用。这些知识点不是孤立的,它们会在一个完整的场景中串联起来,让你看到它们各自扮演什么角色、为什么要这样设计。
2. 整体设计与思路拆解:继承体系怎么搭才合理
2.1 为什么让椭圆继承圆,而不是反过来
先把这个继承方向的问题说透。从纯数学角度看,圆是椭圆的特殊情况,所以直觉上应该是圆继承椭圆。但实际编码时,我们得考虑基类应该包含哪些成员、子类需要扩展什么。如果让圆继承椭圆,那椭圆类就得定义长半轴和短半轴两个属性,圆类继承后需要保证两个半轴始终相等,这就需要在圆类中额外维护约束逻辑,反而增加了复杂度。
反过来,让椭圆继承圆,圆类只需要一个半径属性,提供基础的面积和周长计算。椭圆类继承后,把半径当作长半轴来用,再增加一个短半轴属性,重写面积和周长的计算逻辑。这样基类保持简洁,子类在基类基础上做扩展,符合“基类通用、子类特化”的设计原则。
当然,这种设计也有一个需要注意的地方:圆类中基于半径的计算逻辑,在椭圆类中必须被重写,否则会得到错误的结果。所以圆类中的面积和周长计算方法应该声明为virtual,让椭圆类有机会用override来提供自己的实现。这是整个继承体系能否正确工作的关键。
2.2 两个接口的分工:IComparable和IComparer 各管什么
IComparable和IComparer 这两个接口经常被混淆,我在初学的时候也搞不清楚为什么有了一个还要另一个。简单来说,IComparable是“自身可比较”,实现这个接口的类需要提供一个CompareTo方法,定义“我自己和另一个同类对象谁大谁小”的规则。而IComparer 是“外部比较器”,它是一个独立的类,专门用来定义某种特定的比较规则,比如按面积排序、按周长排序、按长半轴排序等等。
打个比方,IComparable就像是每个人自己心里有一杆秤,知道自己和别人比谁重谁轻。而IComparer 就像是裁判手里的不同评分标准,同一个比赛可以按体重评分,也可以按身高评分,标准是外挂的,不影响选手本身。
在这个Demo中,Ellipse类实现IComparable接口,默认按面积大小进行比较。同时我们再创建一个EllipseAreaComparer类实现IComparer
2.3 构造函数链的设计:基类参数怎么传
继承体系中的构造函数链是一个容易出错的地方。Ellipse类继承Circle类,创建Ellipse对象时,需要先初始化基类Circle的部分,再初始化自己新增的短半轴属性。C#中通过base关键字来调用基类构造函数。
具体来说,Circle类有一个构造函数接收半径参数。Ellipse类的构造函数需要接收长半轴和短半轴两个参数,其中长半轴传给基类构造函数作为半径,短半轴自己保留。写法是public Ellipse(double semiMajor, double semiMinor) : base(semiMajor)。这样基类的半径属性就被初始化为长半轴的值,椭圆类再额外保存短半轴。
这里有个细节需要注意:如果基类没有提供无参构造函数,子类构造函数必须通过base显式调用基类的有参构造函数,否则编译不通过。这是C#编译器强制的规则,初学者经常在这里卡住。
2.4 属性验证与异常处理的设计考量
几何图形类有一个基本要求:边长、半径这些维度参数不能为负数或零。所以在属性的set访问器中需要做验证,如果传入非法值就抛出ArgumentException。这不仅是代码健壮性的问题,也是接口契约的一部分——调用者需要知道什么情况下会出异常。
我在设计时把验证逻辑放在属性setter中,而不是构造函数中,这样无论是构造时赋值还是后续修改属性值,验证都会生效。验证的阈值设定为必须大于0,因为半径为0或负数的圆和椭圆在几何上没有意义。异常信息要写清楚是哪个参数出了问题、期望的范围是什么,方便调用者排查。
3. 核心细节解析与实操要点:代码怎么写才规范
3.1 Circle类的完整实现与虚方法设计
先来看Circle类的实现。这个类作为基类,需要提供半径属性、面积计算、周长计算,以及ToString的友好输出。面积和周长的计算方法要声明为virtual,因为椭圆类需要重写它们。
public class Circle : IComparable<Circle> { private double _radius; public double Radius { get => _radius; set { if (value <= 0) throw new ArgumentException("半径必须大于0", nameof(value)); _radius = value; } } public Circle(double radius) { Radius = radius; } public virtual double GetArea() { return Math.PI * _radius * _radius; } public virtual double GetPerimeter() { return 2 * Math.PI * _radius; } public int CompareTo(Circle other) { if (other == null) return 1; return GetArea().CompareTo(other.GetArea()); } public override string ToString() { return $"圆[半径={_radius:F2}, 面积={GetArea():F2}, 周长={GetPerimeter():F2}]"; } }这里有几个设计决策值得说明。第一,Radius属性用私有字段加属性访问器的形式,而不是自动属性,因为需要在setter中做验证。第二,GetArea和GetPerimeter声明为virtual,这是椭圆类能够重写的前提。第三,Circle类也实现了IComparable
关于CompareTo方法的实现,有一个经典写法是return GetArea().CompareTo(other.GetArea()),直接利用double类型的CompareTo方法。但要注意处理other为null的情况,按照惯例,任何对象与null比较都应该返回正数,表示当前对象“大于”null。
3.2 Ellipse类的继承实现与接口落地
Ellipse类继承Circle类,同时实现IComparable
public class Ellipse : Circle, IComparable<Ellipse>, IComparer<Ellipse> { private double _semiMinor; public double SemiMajor => Radius; public double SemiMinor { get => _semiMinor; set { if (value <= 0) throw new ArgumentException("短半轴必须大于0", nameof(value)); _semiMinor = value; } } public Ellipse(double semiMajor, double semiMinor) : base(semiMajor) { SemiMinor = semiMinor; } public override double GetArea() { return Math.PI * SemiMajor * SemiMinor; } public override double GetPerimeter() { double a = SemiMajor; double b = SemiMinor; double h = Math.Pow(a - b, 2) / Math.Pow(a + b, 2); return Math.PI * (a + b) * (1 + (3 * h) / (10 + Math.Sqrt(4 - 3 * h))); } public int CompareTo(Ellipse other) { if (other == null) return 1; return GetArea().CompareTo(other.GetArea()); } public int Compare(Ellipse x, Ellipse y) { if (x == null && y == null) return 0; if (x == null) return -1; if (y == null) return 1; return x.GetArea().CompareTo(y.GetArea()); } public override string ToString() { return $"椭圆[长半轴={SemiMajor:F2}, 短半轴={SemiMinor:F2}, 面积={GetArea():F2}, 周长={GetPerimeter():F2}]"; } }这段代码里有几个关键点需要展开讲。首先是SemiMajor属性,它直接返回基类的Radius属性,因为我们在构造函数中把长半轴传给了基类作为半径。这样做的好处是不需要额外维护一个长半轴字段,直接复用基类的存储。
其次是椭圆周长公式的选择。椭圆的周长没有精确的初等公式,需要用无穷级数或近似公式。这里采用的是Ramanujan近似公式,精度相当高,对于一般应用场景完全够用。公式中h是一个中间变量,计算过程在代码中已经体现。如果你对精度要求不高,也可以用更简单的近似公式,但Ramanujan公式的额外计算量很小,推荐使用。
然后是CompareTo和Compare两个方法的区别。CompareTo是IComparable
3.3 单独的比较器类:更灵活的外部比较方案
虽然Ellipse类自己实现了IComparer
public class EllipsePerimeterComparer : IComparer<Ellipse> { public int Compare(Ellipse x, Ellipse y) { if (x == null && y == null) return 0; if (x == null) return -1; if (y == null) return 1; return x.GetPerimeter().CompareTo(y.GetPerimeter()); } } public class EllipseSemiMajorComparer : IComparer<Ellipse> { public int Compare(Ellipse x, Ellipse y) { if (x == null && y == null) return 0; if (x == null) return -1; if (y == null) return 1; return x.SemiMajor.CompareTo(y.SemiMajor); } }这两个比较器分别按周长和长半轴排序。有了它们,你可以在不修改Ellipse类的前提下,灵活地按不同规则对椭圆集合进行排序。这就是IComparer 接口的核心价值所在。
注意:在实现Compare方法时,null值的处理顺序很重要。按照惯例,null被认为小于任何非null对象。如果两个参数都是null,返回0。如果第一个是null第二个不是,返回-1。如果第一个不是null第二个是,返回1。这个规则要严格遵守,否则排序结果可能不符合预期。
3.4 泛型与非泛型接口的选择依据
标题中提到了IComparable接口和IComparer 接口,前者是非泛型版本,后者是泛型版本。在实际编码中,我建议优先使用泛型版本IComparable 和IComparer ,因为它们在编译期就能做类型检查,避免了装箱拆箱的性能损耗,也减少了运行时类型转换的错误。
非泛型IComparable的CompareTo方法接收object类型参数,这意味着你可以传入任何类型的对象,编译器不会报错,但运行时会抛出ArgumentException。泛型版本IComparable
所以在这个Demo中,我让Ellipse类实现的是IComparable
4. 实操过程与核心环节实现:从零搭建完整Demo
4.1 项目结构与文件组织
这个Demo不需要复杂的项目结构,一个控制台应用程序就够了。我建议把不同的类放在不同的文件中,这样结构清晰,也符合实际项目的组织习惯。具体文件划分如下:
- Circle.cs:圆类,作为基类
- Ellipse.cs:椭圆类,继承Circle并实现接口
- EllipsePerimeterComparer.cs:按周长比较的外部比较器
- EllipseSemiMajorComparer.cs:按长半轴比较的外部比较器
- Program.cs:入口程序,包含测试代码
如果你用的是Visual Studio或者Rider,直接新建一个控制台项目,然后添加这些类文件即可。用dotnet CLI的话,dotnet new console创建项目,再手动添加文件。
4.2 逐步实现与关键代码解析
先写Circle类。前面已经给出了完整代码,这里补充几个实现细节。Math.PI是C#内置的圆周率常量,精度足够一般计算使用。ToString方法中用了F2格式化字符串,保留两位小数,方便输出查看。CompareTo方法中先判断other是否为null,这是实现IComparable 接口的标准做法。
再写Ellipse类。构造函数通过base(semiMajor)把长半轴传给基类,基类的Radius属性被设置为长半轴的值。SemiMajor属性直接返回Radius,不需要额外字段。SemiMinor属性有自己的字段和验证逻辑。GetArea和GetPerimeter都用了override关键字重写基类的虚方法。
椭圆周长公式这里再展开说一下。Ramanujan近似公式的表达式是:P ≈ π(a+b)(1 + 3h/(10+√(4-3h))),其中h = (a-b)²/(a+b)²。这个公式的误差非常小,对于a和b比例不超过3的情况,相对误差小于百万分之一。代码中Math.Pow(a-b, 2)计算(a-b)的平方,Math.Sqrt(4-3*h)计算平方根,整个表达式直接翻译公式即可。
然后是Program.cs中的测试代码。我们需要创建几个椭圆对象,测试面积计算、周长计算、默认排序、外部比较器排序等功能。
class Program { static void Main(string[] args) { var ellipses = new List<Ellipse> { new Ellipse(5, 3), new Ellipse(4, 4), new Ellipse(6, 2), new Ellipse(3, 1), new Ellipse(7, 5) }; Console.WriteLine("=== 原始列表 ==="); foreach (var e in ellipses) Console.WriteLine(e); Console.WriteLine("\n=== 默认排序(按面积,使用IComparable) ==="); ellipses.Sort(); foreach (var e in ellipses) Console.WriteLine(e); Console.WriteLine("\n=== 按周长排序(使用IComparer) ==="); ellipses.Sort(new EllipsePerimeterComparer()); foreach (var e in ellipses) Console.WriteLine(e); Console.WriteLine("\n=== 按长半轴排序(使用IComparer) ==="); ellipses.Sort(new EllipseSemiMajorComparer()); foreach (var e in ellipses) Console.WriteLine(e); Console.WriteLine("\n=== 使用Ellipse自身作为比较器 ==="); ellipses.Sort(new Ellipse(1, 1)); foreach (var e in ellipses) Console.WriteLine(e); } }这段测试代码覆盖了所有核心功能。ellipses.Sort()不带参数时使用Ellipse的CompareTo方法,即按面积排序。ellipses.Sort(new EllipsePerimeterComparer())传入外部比较器,按周长排序。ellipses.Sort(new EllipseSemiMajorComparer())按长半轴排序。最后一行ellipses.Sort(new Ellipse(1, 1))把Ellipse对象自身作为IComparer
4.3 运行结果验证与预期输出
运行上面的代码,你会看到类似这样的输出。原始列表按创建顺序显示。默认排序后,椭圆按面积从小到大排列。按周长排序后,顺序会变化,因为面积大的椭圆不一定周长大。按长半轴排序后,顺序又不一样。最后用Ellipse自身作为比较器,结果和默认排序一致,因为两者都是按面积比较。
这个验证过程很重要,它能帮你确认继承体系中的虚方法重写是否生效、接口实现是否正确、比较器是否按预期工作。如果某个排序结果不对,你可以逐步排查是CompareTo方法的逻辑问题,还是Compare方法的参数处理问题。
4.4 参数计算与边界情况测试
除了正常情况,还需要测试边界情况。比如创建一个长短半轴相等的椭圆,它实际上就是一个圆,面积应该等于πr²。再比如尝试创建短半轴为0或负数的椭圆,应该抛出ArgumentException。这些测试能验证属性验证逻辑是否生效。
// 测试长短半轴相等的特殊情况 var circleLikeEllipse = new Ellipse(5, 5); Console.WriteLine($"长短半轴相等时面积: {circleLikeEllipse.GetArea():F4}"); Console.WriteLine($"圆的面积公式计算: {Math.PI * 5 * 5:F4}"); // 测试异常情况 try { var invalid = new Ellipse(-1, 2); } catch (ArgumentException ex) { Console.WriteLine($"捕获异常: {ex.Message}"); }长短半轴相等时,椭圆退化为圆,面积公式πab变成πa²,和圆的面积公式一致。这说明我们的重写逻辑是正确的。异常测试验证了属性setter中的验证逻辑确实在构造函数执行时被触发了。
5. 常见问题与排查技巧实录:踩过的坑都在这了
5.1 构造函数链断裂导致的编译错误
这是初学者最容易遇到的问题。如果你在Ellipse类的构造函数中没有写: base(semiMajor),编译器会报错,提示基类Circle不包含无参构造函数。因为一旦你为Circle类定义了有参构造函数,编译器就不会再自动生成无参构造函数了。子类构造函数必须显式调用基类的某个构造函数,否则编译不通过。
解决方法就是在子类构造函数后面加上: base(参数列表),把需要的参数传给基类。这个问题的本质是C#的构造函数执行顺序:先执行基类构造函数,再执行子类构造函数。所以基类需要的初始化数据必须通过base传递上去。
5.2 虚方法未重写导致的计算错误
如果你在Ellipse类中忘记用override关键字重写GetArea方法,那么调用GetArea时会执行Circle类的实现,用半径的平方乘以π来计算面积。对于椭圆来说,这个结果只有在长短半轴相等时才正确,其他情况都是错的。
这个问题的排查方法是:创建一个长短半轴不相等的椭圆,手动计算面积πab,然后和程序输出的面积对比。如果不一致,就检查GetArea方法是否被正确重写。同样的问题也可能出现在GetPerimeter方法上。
5.3 接口实现不完整导致的编译错误
实现IComparable
常见的签名错误包括:CompareTo的参数写成了object而不是Ellipse,Compare方法的参数顺序写反了,返回值类型写成了bool而不是int。这些错误编译器都会明确指出,仔细看错误信息就能定位。
5.4 排序结果不符合预期的排查思路
如果排序结果不对,比如按面积排序后顺序还是乱的,排查步骤如下。首先检查CompareTo或Compare方法的返回值逻辑,确认比较的是正确的属性。其次检查是否使用了正确的排序方法,List<T>.Sort()使用默认比较器即IComparable ,List<T>.Sort(IComparer<T>)使用指定的外部比较器。最后检查是否有null值混入了集合,null值的处理逻辑是否正确。
还有一个容易忽略的问题是:如果Compare方法返回的int值溢出,排序结果也会出错。比如用减法来实现比较return (int)(x.GetArea() - y.GetArea()),当两个面积相差很大时,int转换可能溢出。正确的做法是用CompareTo方法,它内部处理了溢出问题。
5.5 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 编译错误:基类不包含无参构造函数 | 子类构造函数未调用base | 检查子类构造函数是否有base调用 | 添加: base(参数) |
| 面积计算结果错误 | 虚方法未重写 | 对比手动计算结果 | 添加override关键字 |
| 编译错误:未实现接口成员 | 方法签名不匹配 | 查看编译器错误信息 | 修正方法签名 |
| 排序结果混乱 | 比较逻辑错误或溢出 | 检查CompareTo返回值 | 使用CompareTo而非减法 |
| 运行时异常:参数超出范围 | 属性验证触发 | 查看异常信息中的参数名 | 传入合法值 |
| 周长计算结果偏差大 | 近似公式精度问题 | 对比精确值 | 换用更高精度公式 |
实操心得:在实现CompareTo方法时,我习惯先写
if (other == null) return 1;这一行,然后再写具体的比较逻辑。这个习惯能避免很多null引用异常。另外,如果类中有多个属性需要参与比较,建议先比较主属性,主属性相等时再比较次属性,这样排序结果更稳定。
5.6 性能优化与代码改进建议
这个Demo的代码在功能上是完整的,但如果要在实际项目中使用,还有几个优化方向。第一,GetArea和GetPerimeter方法如果会被频繁调用,可以考虑缓存计算结果,避免重复计算。第二,如果椭圆对象需要作为字典的键使用,还需要重写GetHashCode和Equals方法。第三,如果比较逻辑经常变化,可以把比较器设计成可配置的,通过构造函数接收比较字段的枚举值。
另外,关于IComparable和IComparer 的选择,我的经验是:如果类有一个天然的、默认的排序规则,就实现IComparable 。如果需要多种排序方式,就为每种方式创建一个IComparer 实现类。不要把所有的比较逻辑都塞进类本身,那样会导致类越来越臃肿,违反单一职责原则。
我在实际项目中还遇到过一个情况:同一个类在不同场景下需要按不同字段排序,而且排序字段是运行时决定的。这种场景下,可以用Lambda表达式来创建比较器,比如ellipses.Sort((a, b) => a.SemiMinor.CompareTo(b.SemiMinor)),这样不需要预先定义比较器类,代码更简洁。但这种方式的可读性和可复用性不如独立的比较器类,适合一次性使用的场景。
最后分享一个调试技巧:当排序结果不符合预期时,可以在CompareTo方法中加一行日志输出,打印两个比较对象的关键属性和返回值,这样能快速定位是哪一对比较出了问题。这个方法在排查复杂排序逻辑时特别有效。