C# 面向对象

1. 面向对象

1.1 面向对象概念

在C#中,面向对象编程(Object-Oriented Programming,简称OOP)是一种编程范式,它将程序中的数据和操作数据的方法组织为对象,通过相互之间的交互来实现程序的功能。

面向对象编程的主要概念包括类(Class)、对象(Object)、封装(Encapsulation)、继承(Inheritance)和多态(Polymorphism)。

  • 类(Class):类是面向对象编程的基本构建块,它定义了一个对象的属性和行为。类可以看作是对象的蓝图或模板。在C#中,可以使用class关键字定义类。

  • 对象(Object):对象是类的实例化结果。通过创建类的对象,可以使用类中定义的属性和方法来操作数据。对象是具体的、可操作的实体。

  • 封装(Encapsulation):封装是面向对象编程的一种特性,它将数据和操作数据的方法封装在一个类中,通过定义公共的接口来访问数据,隐藏了数据的具体实现细节。封装提供了数据的安全性和代码的复用性。

  • 继承(Inheritance):继承是一种机制,允许一个类继承另一个类的属性和方法。继承可以创建类的层次结构,其中子类(派生类)可以继承父类(基类)的特性,并可以添加自己的特定功能。通过继承,可以实现代码的重用和扩展。

  • 多态(Polymorphism):多态是指同一个方法名可以根据调用对象的不同而表现出不同的行为。多态性可以通过方法的重写(Override)和方法的重载(Overload)来实现。多态性提高了代码的灵活性和可扩展性。

在C#中,可以使用类、对象、封装、继承和多态等面向对象的概念来设计和实现复杂的应用程序。通过使用这些概念,可以将程序的逻辑划分为更小的模块,提高代码的可维护性和可读性。C# 不是一种纯粹的面向对象编程语言。C# 提供了多种编程范例。然而,面向对象是 C# 的一个重要概念,也是 .NET 提供的所有库的核心原则。本章的重点是继承和多态性。

1.2 如何将面向对象融入到编程中

将面向对象融入编程中是一种软件开发的方法论,它将问题领域划分为一组相互关联的对象,并通过定义对象的属性和行为来解决问题。下面是一些将面向对象融入编程的一般步骤:

  • 定义对象:首先,需要识别问题领域中的实体,并将其抽象为对象。对象是具有状态(属性)和行为(方法)的实体。例如,在一个图书馆管理系统中,可能会定义书籍、图书馆、读者等对象。

  • 建立类:类是对象的模板或蓝图,描述了对象具有的属性和方法。通过创建类,可以实例化多个对象。例如,在图书馆管理系统中,可以创建一个名为"Book"的类,其中定义了书籍对象的属性和方法。

  • 封装和信息隐藏:面向对象的一个重要概念是封装和信息隐藏。封装指的是将数据和方法组合在一起,形成一个独立的单元。信息隐藏则是限制对对象内部实现的直接访问,只允许通过公共接口进行访问。这可以防止不合理的访问和修改对象的内部状态。

  • 继承:继承是面向对象的另一个重要概念,它允许一个类继承另一个类的属性和方法。通过继承,可以实现代码的重用和层次结构的建立。例如,在图书馆管理系统中,可以创建一个名为"LibraryMember"的类,并使其继承自"Person"类,以便重用"Person"类的属性和方法。

  • 多态性:多态性允许对象在不同的上下文中表现出不同的行为。通过多态性,可以根据对象的具体类型来调用适当的方法。这提供了更大的灵活性和可扩展性。例如,在图书馆管理系统中,可以使用多态性来处理不同类型的图书馆成员对象,而无需关心具体的类型。

  • 设计模式:面向对象编程还涉及设计模式的使用,设计模式是解决特定问题的通用解决方案。它们提供了经过验证的方法来解决常见的设计问题,促进了代码的可维护性、重用性和可读性。

这只是面向对象编程的一些基本概念和步骤,实际应用中可能还涉及其他方面,例如对象关系映射(ORM)、接口和抽象类等。通过理解这些概念并应用它们,可以更好地组织和管理复杂的软件系统。

2. 封装

封装是面向对象编程中的一个重要概念,它通过将数据和方法组合在一起形成一个独立的单元,控制对象的访问和操作方式。封装的原理是将对象的内部实现细节隐藏起来,只暴露必要的接口供外部使用。

封装的目的是为了实现信息隐藏和保护对象的内部状态。通过隐藏对象的内部实现细节,我们可以防止外部代码直接访问和修改对象的状态,从而确保对象的一致性和完整性。同时,封装还可以提供更好的抽象,使代码模块化、可维护性和可重用性更高。

在面向对象编程中,封装的实现通常依赖于以下几个概念:

  • 访问修饰符:访问修饰符用于控制对象的成员(属性和方法)的可见性和访问权限。常见的访问修饰符有:

    • 公有(public):公有成员可以在任何地方被访问。

    • 私有(private):私有成员只能在同一个类的内部被访问。

    • 受保护(protected):受保护成员可以在同一个类及其子类中被访问。

    通过合理使用访问修饰符,可以将对象的内部状态和实现细节隐藏起来,只暴露必要的公共接口。

  • Getter 和 Setter 方法:Getter 和 Setter 方法用于读取和修改对象的属性值。通过使用这些方法,可以控制对属性的访问和修改,并在必要时执行额外的逻辑或验证。通常,Getter 和 Setter 方法被定义为公有方法,以便外部代码可以通过它们访问和修改属性的值,而不需要直接访问属性本身。

  • 数据封装:数据封装是将数据和操作数据的方法封装在一起,形成一个类。在类中,可以将属性定义为私有,并提供公共的方法来访问和操作这些属性。这样一来,外部代码无法直接访问和修改属性,只能通过公共方法进行间接操作。

    • 例如,一个名为"Person"的类可以包含私有的属性"age"和公共的方法"getAge()“和"setAge()”。外部代码无法直接访问"age"属性,而是通过调用"getAge()"和"setAge()"方法来获取和修改年龄的值。
  • 类和对象的关系:在面向对象编程中,类是对象的模板或蓝图,而对象是类的实例。通过创建对象,我们可以实现对属性和方法的封装。对象之间的通信和交互通常通过调用对象的公共方法来实现,而无需关注对象的内部实现细节。

封装的实现可以提高代码的可维护性和可读性,减少代码的耦合性,并隐藏对象的实现细节。它允许我们专注于对象的行为而不是内部细节,从而提高代码的抽象水平和可重用性。

3. 继承

3.1 继承的类型

首先介绍一些面向对象(Object-Oriented,OO)术语,看看 C# 在继承方面支持和不支持的功能。

  • 单重继承:表示一个类可以派生自一个基类。C# 就采用这种继承。

  • 多重继承:多重继承允许一个类派生自多个类。C# 不支持类的多重继承,但允许接口的多重继承。

  • 多层继承:多层继承允许继承有更大的层次结构。类 B 派生自类 A,类C 又派生自类 B。其中,类 B 也成为中间基类,C# 支持它,也很常用。

  • 接口继承:定义了接口的继承。这里允许多重继承。

3.2 多重继承

一些语言(如C++)支持所谓的“多重继承”,及一个类派生自多个类。对于实现继承,多重继承会给生成的代码增加复杂性,还会带来一些开销。

而 C# 又允许类型派生自多个接口。一个类型可以实现多个接口。这说明,C# 类可以派生自另一个类和任意多个接口。更准确地说,因为 System.Object 是一个公共的基类,所以每个 C# 类(除了 Object 类之外)都有一个基类,还可以有任意多个基接口。

3.3 结构和类

使用结构的一个限制是结构不支持继承,但每个结构都自动派生自 System.ValueType。不能编码实现结构的类型层次,但结构可以实现接口。换言之,**结构并不支持实现继承,但支持接口继承。**定义的结构和类可以总结为:

  • 结构总是派生自 System.ValueType,它们还可以派生自任意多个接口。

  • 类总是派生自 System.Object 或用户选择的另一个类,它们还可以派生自任意多个接口。

4. 实现继承

class MyDerivedClass : MyBaseClass{} // 继承自定义类
public class MyDerivedClass : MyBaseClass, IInterface1, IInterface2 {} // 继承自定义类和(实现)任意多个接口
public struct MyDerivedStruct : IInterface1, IInterface2 {} // 继承(实现)任意多个接口
class MyClass : /* 隐式继承*/ System.Object // 默认继承 System.Object

下面的例子定义了基类 Shape。无论是矩形还是椭圆,形状都有一些共同点:形状都有位置和大小。定义相应的类时,位置和大小应包含在 Shape 中。

public class Position
{
    public int X { get; set; }
    public int Y { get; set; }
}

public class Size
{
    public int Width { get; set; }
    public int Height { get; set; }
}

public class Shape
{
    public Position Position { get; } = new Position();
    public Size Size { get; } = new Size();
}

4.1 虚方法

在C#中,使用virtual关键字可以声明一个方法为虚方法。虚方法允许子类重写(覆盖)该方法,从而实现多态性。

public virtual void Draw() => Console.WriteLine($"Shape with {Position} and {Size}");

也可以把属性声明为virtual。对于虚属性或重写属性,语法与非虚属性相同:

public virtual Size Size { get; set; }

当然,也可以给虚属性使用完整的属性语法。

Size m_Size;
public virtual Size Size
{
    get => m_Size;
    set => m_Size = value;
}

为简单起见,下面的讨论将主要集中于方法,但其规则也适用于属性。

C# 中虚函数的概念与标准的 OOP 的概念相同:可以在派生类中重写虚函数。在调用方法时,会调用该类对象的合适方法。在 C# 中,函数在默认情况下不是虚拟的,但(除了构造函数以外)可以显式地声明为virtual。这遵循 C++ 的方式,即从性能的角度来看,除非显式指定,否则函数就不是虚拟的。而在 Java 中,所有的函数都是虚拟的。但 C# 的语法与 C++ 的语法不同,因为 C# 要求在派生类的函数重写另一个函数时,要使用override关键字显式声明。

public class Rectangle : Shape
{
    public override void Draw() => Console.WriteLine($"Rectangle with {Position} and {Size}");
}

重写方法的语法避免了 C++ 中很容易发生的潜在运行错误:当派生类的方法签名无意中与基类版本略有差别时,该方法就不能重写基类的方法。在 C# 中,这会出现一个编译错误,因为编译器会认为函数已标记为 override,但没有重写其基类的方法。

public class Position
{
    public int X { get; set; }
    public int Y { get; set; }
    public override string ToString() => $"X: {X}, Y: {Y}";
}

public class Size
{
    public int Width { get; set; }
    public int Height { get; set; }
    public override string ToString() => $"Width: {Width}, Height: {Height}";
}

**注意:重写基类的方法时,签名(所有类型成员和方法名)和返回类型必须完全匹配。**否则,以后创建的新成员就不覆盖基类成员。

public class InheritClass
{
    public static void Main()
    {
        var rectangle = new Rectangle();
        rectangle.Position.X = 33;
        rectangle.Position.Y = 22;
        rectangle.Size.Width = 200;
        rectangle.Size.Height = 100;
        rectangle.Draw(); // Rectangle with X: 33, Y: 22 and Width: 200, Height: 100
    }
}

成员字段和静态函数都不能声明为 virtual,因为这个概念只对类中的实例函数成员有意义。

4.2 多态性

4.2.1 多态性示例1

使用多态性,可以动态地定义调用的方法,而不是在编译期间定义。编译器创建一个虚拟方法表(vtable),其中列出了可以在运行期间调用的方法,它根据运行期间的类型调用方法。

在下面的例子中,DrawShape() 方法接收一个 Shape 参数,并调用 Shape 类的 Draw() 方法。

public static void DrawShape(Shape shape) => shape.Draw();

使用之前创建的矩形调用方法。尽管方法声明为接收一个 Shape 对象,但任何派生 Shape 的类型都可以传递给这个方法:

DrawShape(rectangle);

运行这个程序,查看 Rectangle.Draw() 方法而不是 Shape.Draw() 方法的输出。输出行从 Rectangle 开始。如果基类的方法不是虚拟方法或没有重写派生类的方法,就使用所声明对象(Shape)的类型的 Draw() 方法。

public class Rectangle : Shape
{
    // public override void Draw() => Debug.Log($"Rectangle with {Position} and {Size}");
}

public class InheritClass
{
    public static void Main()
    {
        var rectangle = new Rectangle();
        rectangle.Position.X = 33;
        rectangle.Position.Y = 22;
        rectangle.Size.Width = 200;
        rectangle.Size.Height = 100;
        DrawShape(rectangle); // Shape with X: 33, Y: 22 and Width: 200, Height: 100
    }

    public static void DrawShape(Shape shape) => shape.Draw();
}

4.2.2 多态性示例2

class Animal
{
    public virtual void MakeSound()
    {
        Console.WriteLine("Animal makes a sound");
    }
}

class Dog : Animal
{
    public override void MakeSound()
    {
        Console.WriteLine("Dog barks");
    }
}

class Cat : Animal
{
    public override void MakeSound()
    {
        Console.WriteLine("Cat meows");
    }
}

class Program
{
    static void Main(string[] args)
    {
        Animal animal = new Animal();
        Animal dog = new Dog();
        Animal cat = new Cat();

        animal.MakeSound();  // 输出: Animal makes a sound
        dog.MakeSound();     // 输出: Dog barks
        cat.MakeSound();     // 输出: Cat meows
    }
}

在上面的示例中,Animal是父类,DogCat是子类。父类Animal有一个虚方法MakeSound(),而子类DogCat分别重写了这个方法。

Main方法中,我们创建了一个父类Animal的实例animal,以及子类DogCat的实例dogcat。然后,我们调用它们的MakeSound()方法。

当调用animal.MakeSound()时,由于animalAnimal类型的引用,所以调用的是父类Animal中的实现,输出"Animal makes a sound"。

当调用dog.MakeSound()时,由于dogDog类型的引用,但是MakeSound()方法是虚方法并在子类Dog中重写,所以调用的是子类Dog中的实现,输出"Dog barks"。

当调用cat.MakeSound()时,由于catCat类型的引用,但是MakeSound()方法是虚方法并在子类Cat中重写,所以调用的是子类Cat中的实现,输出"Cat meows"。

这就是多态性的实现:相同的方法调用可以根据对象的实际类型而执行不同的实现。通过使用虚方法和方法重写,可以实现多态性,提高代码的灵活性和可扩展性。

4.3 隐藏方法

4.3.1 隐藏方法示例1

如果签名相同的方法在基类和派生类中都进行了声明,但该方法没有分别声明为 virtual 和 override,派生类方法就会隐藏基类方法。

在大多数情况下,是要重写方法而不是隐藏方法,因为隐藏方法会造成对于给定类的实例调用错误方法的危险。

C# 语法可以确保开发人员在编译时收到这个潜在错误的警告,从而使隐藏方法更加安全。

public class Shape
{
    public virtual void MoveBy(int x, int y) { }
}

public class Ellipse : Shape
{
    public new void MoveBy(int x, int y) // 假设这个 MoveBy() 方法是另作他用(与基类用处不同),则需要添加 new 关键字消除编译器的编译警告(隐藏基类方法)。
    {
        Position.X += x;
        Position.Y += y;
    }
}

不使用 new 关键字,也可以重命名方法,或者,如果基类的方法声明为 virtual,且用作相同的目的,就重写它。然而,如果其他方法已经调用此方法,简单的重命名会破坏其他代码。

注意:new 方法修饰符不应该故意用于隐藏基类的成员。这个修饰符的主要目的是处理版本冲突,在修改派生类后,响应基类的变化。

4.3.2 隐藏方法示例2

class Animal
{
    public void MakeSound()
    {
        Console.WriteLine("Animal makes a sound");
    }
}

class Dog : Animal
{
    public new void MakeSound() // 使用了 new 关键字
    {
        Console.WriteLine("Dog barks");
    }
}

class Program
{
    static void Main(string[] args)
    {
        Animal animal = new Animal();
        Animal dog = new Dog();

        animal.MakeSound();  // 输出: Animal makes a sound
        dog.MakeSound();     // 输出: Dog barks
    }
}

在上面的示例中,父类Animal有一个MakeSound()方法。子类Dog中也定义了一个同名的方法,并使用new关键字修饰。这意味着子类中的方法将隐藏父类中的方法。

Main方法中,我们创建了一个父类Animal的实例animal,以及子类Dog的实例dog。然后,我们调用它们的MakeSound()方法。

当调用animal.MakeSound()时,由于animalAnimal类型的引用,所以调用的是父类Animal中的方法,输出"Animal makes a sound"。

当调用dog.MakeSound()时,由于dogDog类型的引用,并且Dog类中定义了一个与父类同名的方法,并使用new关键字修饰,所以调用的是子类Dog中的方法,输出"Dog barks"。

通过使用new关键字隐藏方法,可以在子类中重新定义父类中的方法,从而覆盖父类中的实现。这在某些特定场景下可能是有用的,但需要小心使用,以确保不会产生混淆和意外的行为。

4.4 调用方法的基类版本

public class Shape
{
    public virtual void Move(Position newPosition)
    {
        Position.X = newPosition.X;
        Position.Y = newPosition.Y;
        Debug.Log($"moves to {Position}");
    }
}

public class Rectangle : Shape
{
    public override void Move(Position newPosition)
    {
        Debug.Log("Rectangle: ");
        base.Move(newPosition);
    }
}

public class InheritClass
{
    public static void Main()
    {
        var rectangle = new Rectangle();
        rectangle.Position.X = 33;
        rectangle.Position.Y = 22;
        rectangle.Size.Width = 200;
        rectangle.Size.Height = 100;
        rectangle.Move(new Position { X = 120, Y = 40 }); // Rectangle moves to X: 120, Y: 40
    }
}

注意:使用 base 关键字,可以调用基类的任何方法——而不仅仅是已重写的方法。

4.5 抽象类和抽象方法

C# 允许把类和方法声明为 abstract。抽象类不能实例化,而抽象方法不能直接实现,必须在非抽象的派生类中重写。

显然,抽象方法本身就是虚拟的(不需要提供 virtual 关键字)。如果类包含抽象方法,则该类也是抽象的,也必须声明为抽象的。

public abstract class Shape
{
    public abstract void Resize(int width, int height);
}
// 从抽象基类中派生类型时,需要实现所有抽象成员。否则编译器会报错。
public class Ellipse : Shape
{
    public override void Resize(int width, int height)
    {
        Size.Width = width;
        Size.Height = height;
    }
}

public static void Main()
{
    Shape shape = new Ellipse();
    DrawShape(shape);
}

4.6 密封类和密封方法

如果不应创建派生自某个自定义类的类,该自定义类就应密封。给类添加 sealed 修饰符,就不允许创建该类的子类。密封一个方法,表示不能重写该方法。

internal sealed class FinalClass { }

internal class DerivedClass : FinalClass /* error: Cannot inherit from sealed class "FinalClass" */ { }

在把类或方法标记为 sealed 时,最可能的情况是:如果在库、类或自己编写的其他类的操作中,类或方法是内部的,则任何尝试重写它的一些功能,都可能导致代码的不稳定。例如,也许没有测试继承,就对继承的设计决策投资。如果是这样,最好把类标记为 sealed。

密封类有另一个原因。对于密封类,编译器知道不能派生类,因此用于虚拟方法的虚拟表可以缩短或消除,以提高性能。string 类是密封的。没有哪个应用程序不使用字符串,最好使这种类型保持最佳性能。把类标记为 sealed 对编译器来说是一个很好的提示。

将一个方法声明为 sealed 的目的类似于一个类。方法可以是基类的重写方法,但是在接下来的例子中,编译器知道,另一个类不能拓展这个方法的虚拟表;它在这里终止继承。

internal class MyBaseClass { public virtual void FinalMethod() { } }

internal class MyClass : MyBaseClass { public sealed override void FinalMethod() { } }

internal class DerivedClass : MyClass
{
    public override void FinalMethod() { } // error: Cannot override inherited method 'void MyClass.FinalMethod()' because it is sealed
}

要在方法或属性上使用 sealed 关键字,必须先从基类上把它声明为要重写的方法或属性。如果基类上不希望有重写的方法或属性,就不要把它声明为 virtual。

4.7 派生类的构造函数

public class Shape
{
    public Size Size { get; } = new Size();
    public Position Position { get; } = new Position();
}

在幕后,编译器会给类创建一个默认的构造函数,把属性初始化器放在这个构造函数中:

public class Shape
{
    public Shape()
    {
        Position = new Position();
        Size = new Size();
    }
    public Position Position { get; }
    public Size Size { get; }
}

当然,实例化派生自 Shape 类的 Rectangle 类型,Rectangle 需要 Position 和 Size,因此在构造派生对象时,调用基类的构造函数。

如果没有在默认构造函数中初始化成员,编译器会自动把引用类型初始化为 null,值类型初始化为0,布尔值初始化为 false。布尔类型是值类型,false 与 0 是一样的,所以这个规则也适用于布尔类型。

对于 Ellipse 类,如果基类定义了默认构造函数,只把所有成员初始化为其默认值,就没有必要创建默认的构造函数。当然,仍可以提供一个构造函数,使用构造函数初始化器,调用基构造函数:

public class Ellipse : Shape
{
    public Ellipse() : base() { }
}

构造函数总是按照层次结构的顺序调用:先调用 System.Object 类的构造函数,再按照层次结构由上向下进行,直到到达编译器要实例化的类为止。

为了实例化 Ellipse 类型,先调用 Object 构造函数,再调用 Shape 构造函数,最后调用 Ellipse 构造函数。这些构造函数都处理它自己类中字段的初始化。

现在,改变 Shape 类的构造函数。不是对 Size 和 Position 属性进行默认的初始化,而是在构造函数内赋值。

public abstract class Shape
{
    public Shape(int width, int height, int x, int y)
    {
        Position = new Position { X = x, Y = y };
        Size = new Size { Width = width, Height = height };
    }
    public Position Position { get; }
    public Size Size { get; }
}

当删除默认构造函数,重新编译程序时,不能编译 Ellipse 和 Rectangle 类,因为编译器不知道应该把什么值传递给基类唯一的非默认值构造函数。这里需要在派生类中创建一个构造函数,用构造函数初始化器初始化基类构造函数:

public class Rectangle : Shape
{
    public Rectangle(int width, int height, int x, int y) : base(width, height, x, y) { }
}

把初始化代码放在构造函数块内太迟了,因为基类的构造函数在派生类的构造函数之前调用。这就是为什么在构造函数块之前声明了一个构造函数初始化器。

如果希望允许使用默认的构造函数创建 Rectangle 对象,仍可以这样做。如果基类的构造函数没有默认的构造函数,也可以这样做,只需要在构造函数初始化器中为基类构造函数指定值。

public Rectangle() : base(width: 0, height: 0, x: 0, y: 0) { }

5. 修饰符

5.1 访问修饰符

修饰符应用于说明
public所有类型或成员任何代码均可以访问该项
protected类型和内嵌类型的所有成员只有派生的类型能访问该项
internal所有类型或成员只能在包含它的程序集中访问该项
private类型和内嵌类型的所有成员只能在它所属的类型中访问该项
protected internal类型和内嵌类型的所有成员只能在包含它的程序集和和派生类型的任何代码中访问该项。实际上,这意味着 protected or internal。
private protected类型和内嵌类型的所有成员访问修饰符 protected internal 表示 protected or internal,与此相反,private protected 将 private 与 protected 组合在一起,表示 private and protected。只允许访问同一程序集中的派生类型,而不允许访问其他程序集中的派生类型。

注意:public、protected 和 private 是逻辑访问修饰符。internal 是一个物理访问修饰符,其边界是一个程序集。

注意,类型定义可以是内部或公有的,这取决于是否希望在包含类型的程序集外部访问它:

public class MyClass{}

不能把类型定义为 protected、private 或 protected internal,因为这些修饰符对于包含在名称空间中的类型没有意义。因此这些修饰符只能应用于成员。但是,可以用这些修饰符定义嵌套的类型(即,包含在其他类型中的类型),因为在这种情况下,类型也具有成员的状态。于是,下面的代码是合法的:

public class OuterClass
{
    protected class InnerClass
    {
    }
}

如果有嵌套的类型,则内部的类型总是可以访问外部类型的所有成员。所以,在上面的代码中,InnerClass中的代码可以访问 OuterClass 的所有成员,甚至可以访问 OuterClass 的私有成员。

5.2 其他修饰符

修饰符应用于说明
new函数成员成员用相同的签名隐藏继承的成员
static所有成员成员不作用于类的具体实例,也称为类成员,而不是实例成员
virtual仅函数成员成员可以由派生类重写
abstract仅函数成员虚拟成员定义了成员的签名,但没有提供实现代码
override仅函数成员成员重写了继承的虚拟或抽象成员
sealed类、方法和属性对于类,不能继承自密封类。对于属性和方法,成员重写已继承的虚拟成员,但任何派生类中的任何成员都不能重写该成员。该修饰符必须与 override 一起使用。
extern仅静态[DllImport]方法成员在外部用另一种语言实现。

6. 接口

如前所述,如果一个类派生自一个接口,声明这个类就会实现某些函数。

public interface IDisposable
{
    void Dispose();
}

上面的代码声明,声明接口在语法上与声明抽象类完全相同,但不允许提供接口中任何成员的实现方式。一般情况下,接口只能包含方法、属性、索引器和事件的声明。

比较接口和抽象类:抽象类可以有实现代码或没有实现代码的抽象成员。然而,接口不能有任何实现代码;它是存粹抽象的。因为接口的成员总是抽象的,所以接口不需要 abstract 关键字。

类似于抽象类,永远不能实例化接口,它只能包含其成员的签名。此外,可以声明接口类型的变量。

接口既不能有构造函数(如何构建不能实例化的对象?)也不能有字段(因为这隐含了某些内部的实现方式)。接口定义也不允许包含运算符重载,但设计语言时总是会讨论这个可能性,未来可能会改变。

在接口定义中还不允许声明成员的修饰符。接口成员总是隐式为 public,不能声明为 virtual。如果需要,就应由实现的类来声明,因此最好实现类来声明访问修饰符。

例如,IDisposable。如果类希望声明为共有类型,以便它实现方法 Dispose(),该类就必须实现 IDisposable。在 C# 中,这表示该类派生自 IDisposable 类。

class SomeClass : IDisposable
{
    public void Dispose()
    {
        throw new NotImplementedException();
    }
}

6.1 定义和实现接口

using UnityEngine;

public interface IBankAccount
{
    void PayIn(decimal amount);    // 缴款
    bool Withdraw(decimal amount); // 提,取(银行账户中的钱款)
    decimal Balance { get; }       // 账户余额
}

public class SaverAccount : IBankAccount
{
    decimal m_Balance;
    public void PayIn(decimal amount) => m_Balance += amount; // 缴款
    public bool Withdraw(decimal amount) // 提,取(银行账户中的钱款)
    {
        if (m_Balance >= amount)
        {
            m_Balance -= amount;
            return true;
        }
        Debug.Log("Withdrawal attempt failed."); // 体现尝试失败
        return false;
    }
    public decimal Balance => m_Balance; // 账户余额
    public override string ToString() => $"Venus Bank Saver: Balance = {m_Balance,6:C}";
}

public class GoldAccount : IBankAccount
{
    decimal m_Balance;
    public void PayIn(decimal amount) => m_Balance += amount; // 缴款
    public bool Withdraw(decimal amount) // 提,取(银行账户中的钱款)
    {
        if (m_Balance >= amount)
        {
            m_Balance -= amount;
            return true;
        }
        Debug.Log("Withdrawal attempt failed."); // 体现尝试失败
        return false;
    }
    public decimal Balance => m_Balance; // 账户余额
    public override string ToString() => $"Venus Bank Saver: Balance = {m_Balance,6:C}";
}

public class InterfaceTest
{
    public static void Main()
    {
        IBankAccount venusAccount = new SaverAccount();
        IBankAccount jupiterAccount = new GoldAccount();
        venusAccount.PayIn(200);
        venusAccount.Withdraw(100);
        Debug.Log(venusAccount.ToString()); // Venus Bank Saver: Balance = ¥100.00
        jupiterAccount.PayIn(500);
        jupiterAccount.Withdraw(600);
        jupiterAccount.Withdraw(100);
        Debug.Log(jupiterAccount.ToString()); // Withdrawal attempt failed. Venus Bank Saver: Balance = ¥400.00
    }
}

在这段代码中,要点是把引用变量声明为 IBankAccount 引用的方式。这表示它们可以指向实现这个接口的任何类的任何实例。但我们只能通过这些引用调用接口的一部分方法——如果要调用由类实现的但不在接口中的方法,就需要把引用强制转换为合适的类型。在这段代码中,我们调用了 ToString()(不是IBankAccount实现的),但没有进行任何显式的强制转换,这只是因为 ToString() 是一个 System.Object() 方法,因此 C# 编译器知道任何类都支持这个方法(换言之,从任何接口到 System.Object 的数据类型强制转换都是隐式的)。

接口引用完全可以看成类引用——但接口引用的强大之处在于,它可以引用任何实现该接口的类。例如,我们可以构造接口数组,其中数组的每个元素都是不同的类。

IBankAccount[] accounts = new IBankAccount[2];
accounts[0] = new SaverAccount();
accounts[1] = new GoldAccount();

6.2 派生的接口

接口可以彼此继承,其方式与类的继承方式相同。

public interface ITransferBankAccount : IBankAccount
{
    bool TransferTo(IBankAccount destination, decimal amount); // 把资金直接转到另一个账户上
}

因为 ITransferBankAccount 派生自 IBankAccount,所以它拥有 IBankAccount 的所有成员和它自己的成员。这表示实现(派生自)

ITransferBankAccount 的任何实现类都必须实现 IBankAccount 的所有方法和 ITransferBankAccount 中定义的新方法 TransferTo()。没有实现所有这些方法就会产生一个编译错误。

注意:TransferTo()方法对于目标账户使用了 IBankAccount 接口引用。这说明接口的用途:在实现并调用这个方法时,不必知道转账的对象类型,只需要知道该对象实现 IBankAccount 即可。

下面说明 ITransferBankAccount:假定 Planetary Bank of Jupiter 还提供了一个当前账户。CurrentAccount 类的大多数实现代码与 SaverAccount 和 GoldAccount 的实现代码相同。

public class CurrentAccount : ITransferBankAccount
{
    decimal m_Balance;
    public void PayIn(decimal amount) => m_Balance += amount; // 缴款
    public bool Withdraw(decimal amount) // 提,取(银行账户中的钱款)
    {
        if (m_Balance >= amount)
        {
            m_Balance -= amount;
            return true;
        }
        Debug.Log("Withdrawal attempt failed."); // 体现尝试失败
        return false;
    }
    public decimal Balance => m_Balance; // 账户余额
    public bool TransferTo(IBankAccount destination, decimal amount)
    {
        bool result = Withdraw(amount);
        if (result)
        {
            destination.PayIn(amount);
        }
        return result;
    }
    public override string ToString() => $"Jupiter Bank Current Account: Balance = {m_Balance,6:C}";
}

void Awake()
{
    IBankAccount venusAccount = new SaverAccount();
    ITransferBankAccount jupiterAccount = new CurrentAccount();
    venusAccount.PayIn(200);
    jupiterAccount.PayIn(500);
    jupiterAccount.TransferTo(venusAccount, 100);
    Debug.Log(venusAccount.ToString());   // Venus Bank Saver: Balance = ¥300.00
    Debug.Log(jupiterAccount.ToString()); // Jupiter Bank Current Account: Balance = ¥400.00
}

7. is 和 as 运算符

is 和 as 运算符示例1

在结束接口和类的继承之前,需要介绍两个与继承有关的重要运算符:is 和 as。

如前所述,可以把具体类型的对象直接分配给基类或接口——如果这些类型在层次结构中有直接关系。

例如:IBankAccount venusAccount = new SaverAccount();​​

如果一个方法接受一个对象类型,现在希望访问 IBankAccount 成员,该怎么办?该对象类型没有 IBankAccount 接口的成员。此时可以进行类型转换。把对象(也可以使用任何接口中任意类型的参数,把它转换为需要的类型)转换为 IBankAccount,再处理它:

public void WorkWithManyDifferentObjects(object o)
{
    IBankAccount account = (IBankAccount)o;
}

只要总是给这个方法提供一个 IBankAccount 类型的对象,这就是有效的。当然,如果接受一个 object 类型的对象,有时就会传递无效的对象。此时会得到 InvalidCastException 异常。在正常情况下接受异常从来都不好,此时应使用 is 和 as 运算符。

不是直接进行类型转换,而应检查参数是否实现了接口 IBankAccount。as 运算符的工作原理类似于类层次结构中的 cast 运算符——它返回对象的引用。然而,它从不抛出 InvalidCastException 异常。相反,如果对象不是所要求的类型,这个运算符就返回 null。这里,最好在使用引用前验证它是否为空,否则以后使用以下引用,就会抛出 NullReferenceException 异常:

public void WorkWithManyDifferentObjects(object o)
{
    IBankAccount account = o as IBankAccount;
    if (account != null)
    {
        // Work with the account
    }
}

除了使用 as 运算符之外,还可以使用 is 运算符。is 运算符根据条件是否满足,对象是否使用特定的类型,返回 true 或 false。如果条件为 true,则将所得的对象写入声明为匹配类型的变量中:

public void WorkWithManyDifferentObjects(object o)
{
    if (o is IBankAccount account)
    {
        // Work with the account
    }
}

注意:向 is 运算符添加变量声明是 C# 7 的一个新特性。这是模式匹配功能的一部分。

在类层次结构内部的类型转换不会抛出基础类型转换的异样,且使用 is 和 as 运算符都是可行的。

is 和 as 运算符示例2

在C#中,isas 是用于类型检查和类型转换的修饰符。它们有不同的功能和使用场景。

  • is 修饰符:

    • is 用于检查对象是否属于特定类型,或者是否可以分配给特定类型。它返回一个布尔值truefalse)。

    • is 的语法是 expression is type,其中 expression 是要检查的表达式,type 是要检查的目标类型。

    • is 运算符用于在运行时判断一个对象是否与指定的类型兼容。如果对象是指定类型的实例或者可以被隐式转换为指定类型,则返回 true,否则返回 false

    • is 运算符在条件语句(如 ifwhile)中经常被用于类型判断和类型筛选。

    • 示例:

object obj = "Hello";
if (obj is string)
{
   string str = (string)obj;
   Console.WriteLine("obj is a string: " + str);
}
  • as 修饰符:

    • as 用于将对象转换为指定类型(或类型的可空形式)。如果转换成功,则返回对象的新类型实例;如果转换失败,则返回 null

    • as 的语法是 expression as type,其中 expression 是要转换的表达式,type 是要转换到的目标类型。

    • as 运算符在进行类型转换时比较安全因为如果转换失败,它不会引发异常,而是返回 null

    • as 运算符通常与条件语句结合使用,用于检查转换是否成功,并根据需要采取进一步的操作。

    • 示例:

object obj = "Hello";
string str = obj as string;
if (str != null)
{
   Console.WriteLine("obj can be safely cast to string: " + str);
}

两者的区别:

  • is 用于类型检查,返回布尔值,指示对象是否与指定类型兼容。

  • as 用于类型转换,返回转换后的对象实例,或者在转换失败时返回 null

两者的使用时机:

  • 使用 is 修饰符当你只关心对象是否与特定类型兼容时。你可以根据 is 的结果执行不同的操作,或者进行类型筛选。
public class Shape
{
    public void Draw() {Console.WriteLine("Drawing a shape");}
}

public class Circle : Shape
{
    public void DrawCircle() {Console.WriteLine("Drawing a circle");}
}

public class Rectangle : Shape
{
    public void DrawRectangle() {Console.WriteLine("Drawing a rectangle");}
}

public class Program
{
    public static void Main()
    {
        Shape shape1 = new Circle();
        Shape shape2 = new Rectangle();

        // 使用 is 进行类型检查
        if (shape1 is Circle)
        {
            Circle circle = (Circle)shape1;
            circle.DrawCircle();
        }
        else if (shape1 is Rectangle)
        {
            Rectangle rectangle = (Rectangle)shape1;
            rectangle.DrawRectangle();
        }

        if (shape2 is Circle)
        {
            Circle circle = (Circle)shape2;
            circle.DrawCircle();
        }
        else if (shape2 is Rectangle)
        {
            Rectangle rectangle = (Rectangle)shape2;
            rectangle.DrawRectangle();
        }
    }
}
  • 使用 as 修饰符当你想要将对象转换为特定类型,并且希望转换失败时返回 null。你可以使用 as 运算符来安全地进行类型转换,而无需手动检查转换是否成功。
public class Animal
{
    public string Name { get; set; }
}

public class Dog : Animal
{
    public void Bark() {Console.WriteLine("Barking...");}
}

public class Cat : Animal
{
    public void Meow() {Console.WriteLine("Meowing...");}
}

public class Program
{
    public static void Main()
    {
        Animal animal1 = new Dog();
        Animal animal2 = new Cat();
        Animal animal3 = new Animal();

        // 使用 as 进行安全的类型转换
        Dog dog1 = animal1 as Dog; // 使用 as 运算符将 animal1 转换为 Dog 类型,并成功地调用 Bark 方法
        if (dog1 != null)
        {
            dog1.Bark();
        }
        else
        {
            Console.WriteLine("animal1 is not a Dog");
        }

        Cat cat1 = animal2 as Cat; // 将 animal2 转换为 Cat 类型,并成功地调用 Meow 方法
        if (cat1 != null)
        {
            cat1.Meow(); // animaal
        }
        else
        {
            Console.WriteLine("animal2 is not a Cat");
        }

        Dog dog2 = animal3 as Dog; // animal3 转换为 Dog 类型,但由于实际类型是 Animal 而不是 Dog,转换失败,as 运算符返回 null
        if (dog2 != null)
        {
            dog2.Bark();
        }
        else
        {
            Console.WriteLine("animal3 is not a Dog");
        }
    }
}

总之,is 用于类型检查和类型筛选,而 as 用于类型转换,返回转换后的对象实例或 null

  • 25
    点赞
  • 38
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值