原则34:避免定义在基类的方法的重写

优质
小牛编辑
125浏览
2023-12-01

当基类给成员命名时,名字就赋予了语义。在任何情况下,子类最好都不要将同一个名字用作其他目的。但为什么有很多其他原因子类会使用同一个名字。它可能想用不同方式实现同一个语义,或者有不同的参数。有时这是语言原生就支持:类设计者可以声明一个虚函数,子类就可以各自实现语义。原则33包含了为什么使用 new 修饰符 可能导致很难发现代码的 bug 。在这个原则里,你将会学到重写基类定义的函数会导致类似的问题。你应该不要重写基类声明的方法。

C# 语言重载解析的规则必然是非常复杂的。所有可能的子类声明的方法,基类的任何方法,以及扩展方法和实现接口的方法都是解析的候选方法。增加了泛型方法和泛型扩展方法,就变得更复杂。使用默认参数,而且我不确定每个人能否确切知道结果是什么。你真的想要这个情况更加复杂?创建的声明在基类函数的重写版本会增加找到最好函数匹配的复杂性。这也增加歧义的可能性。这也增加你的解释和编译器不同的可能性,就自然困扰使用者。解决方法是很简单的:给方法选择不同的名字。这是你的类,你就有足够的光彩去给方法提出一个不同名字,尤其当很容易困扰类的使用者时。

这个指导是直截了当的,然而总是有人会怀疑真的要这么严格。可能是因为重写听起来很像重载。重载虚函数是面向对象语言的核心原则;那明显不是我的意思。重写是使用不同的参数列表创建多个名字一样的方法。重写基类方法真的对重写解析有很多影响?我们从不同的方式看在重写基类中的方法会引起问题。

这个问题有很多组合情况。我们从最简单的开始。很多时候基类和子类更多的是不同参数列表的重写。字啊么所有的例子,任何基类类名以“B”开始,任何子类类名以“D”开始。下这个例子用这个继承关系的类作为参数:

public class B2 {}
public class D2 : B2 {}

下面这个类的方法使用子类(D2)作为参数:

public class B 
{
    public void Foo(D2 parm) 
    {
        Console.WriteLine("In B.Foo"); 
    }
}

显然,这段代码会输出“In B.Foo”:

var obj1 = new D();
obj1.Bar(new D2());

下面,我们在子类中重写这个方法:

public class D : B 
{
    public void Foo(B2 parm) 
    {
        Console.WriteLine("In D.Foo"); 
    }
}

那么,执行下面这段代码会发生什么?

var obj2 = new D(); 
obj2.Foo(new D2()); 
obj2.Foo(new B2());

两行都是输出“In D.Foo”。你调用的都是子类的方法。很多开开发者会认为第一个函数调用会输出“In B.Foo”。然而,即使很简单的重写都会很惊奇。两个调用都解析为 D.Foo 的原因是当有多个候选方法时,编译时继承关系最底端的子类的方法是最好的选择。即使当基类有更好的匹配这个规则都是正确的。当然,也是非常脆弱的。你认为下面的结果是什么:

B obj3 = new D(); 
obj3.Foo(new D2());

上面我的用词非常小心因为 obj3 的编译时类型为 B (你的基类),即使它的运行时类型是 D (你的子类)。 Foo 不是虚函数, obj3.Foo() 一定会被解析为 B.Foo 。

如果你的使用者基础差并想要解析规则跟他们期望的一样,他们就需要使用强制类型转换:

var obj4 = new D();
((B)obj4).Foo(new D2()); 
obj4.Foo(new B2());

如果你的 API 强制你的使用者这样构造,你就会遇到很多挫折。你还可以很容易增加一点困扰。在你的基类 B 增加一个方法:

{
    public void Foo(D2 parm) 
    {
        Console.WriteLine("In B.Foo"); 
    }
    public void Bar(B2 parm) 
    {
        Console.WriteLine("In B.Bar"); 
    }
}

毫无疑问,下面的代码会打印出“In B.Bar”:

var obj1 = new D(); 
obj1.Bar(new D2());

现在,增加另一种重写,包含一个默认参数:

public class D : B 
{
    public void Foo(B2 parm) 
    {
        Console.WriteLine("In D.Foo"); 
    }
    public void Bar(B2 parm1, B2 parm2 = null) 
    {
        Console.WriteLine("In D.Bar"); 
    }
}

希望,你已经看到将会发生什么。同样的代码现在会打印出“In D.Bar”(你又调用子类):

var obj1 = new D(); 
obj1.Bar(new D2());

唯一的调用基类的方法的方式是在调用代码中提供强制类型转换。

这几个例子展示一个参数的方法会遇到的问题。如果你的参数是基于泛型的会变得越来越复杂。假设你增加下面的方法:

public class B 
{
    public void Foo(D2 parm) 
    {
        Console.WriteLine("In B.Foo"); 
    }
    public void Bar(B2 parm) 
    {
        Console.WriteLine("In B.Bar"); 
    }
    public void Foo2(IEnumerable<D2> parm) 
    {
        Console.WriteLine("In B.Foo2"); 
    }
}

进而,在子类添加不同的重写:

public class D : B 
{
    public void Foo(B2 parm) 
    {
        Console.WriteLine("In D.Foo"); 
    }
    public void Bar(B2 parm1, B2 parm2 = null) 
    {
        Console.WriteLine("In D.Bar"); 
    } 
    public void Foo2(IEnumerable<B2> parm) 
    {
        Console.WriteLine("In D.Foo2"); 
    }
}

按照前面的方式调用 Foo2 :

var sequence = new List<D2> { new D2(), new D2() }; 
var obj2 = new D();
obj2.Foo2(sequence);

这回你会认为输出什么?如果你花了心思,你会发现“ In D.Foo2 ”会被输出。你会这个答案半信半疑。这个就是 C# 4.0 的变化。从 C# 4.0 开始,泛型接口支持协变和逆变,这意味 D.Foo2 是参数为 IEnumerable<D2> 的候选方法,尽管它的参数类型是 IEnumerable<B2> 。然后,更早的 C# 版本泛型不具有可变性。也就是说泛型参数是不可变的。在那些版本, 当参数为 IEnumerable<D2> 时,D.Foo2 就不是候选方法。唯一的的候选方法是 B.Foo2 ,在那些版本中它是正确的答案。

上面的代码表明,有时很多复杂的情况你需要强制类型转换帮助编译器选择你想要的方法。在现实世界中,毫无疑问你会遇到需要使用强制类型转换而不是靠编译器选择“最好”的方法的情况,因为类继承关系,实现接口和扩展方法一起组成你想要的方法。但事实上,现实世界丑陋的情况偶尔才发生不意味着你创建更多的重写方法给自己增加更多问题。

现在你就可以在程序员的鸡尾酒会因拥有更深入 C# 重载解析而让你朋友震惊。这很有用的信息,并且你对语言了解的更多,你就会是更好的开发者。但是不要期望你的使用者会有和你一样层次的知识。更重要的是,不要以为使用你的 API 的每个人都对重写解析怎么工作的都有详细的掌握。而是,不要重写在基类声明的方法。那样不会提供任何作用,并且只能是导致你的使用者的困扰。

小结:

记住,重写解析规则是优先选择编译时继承结构最底端的子类的方法,即使基类有更匹配的方法,C# 4.0以后版本泛型也遵循这个规则,避免重写,这则原则的要义就掌握了!