面向接口编程,而非实现 - 初学者理解接口、抽象类和具体类的教程






4.68/5 (73投票s)
在本文中,我们将详细介绍 C# 中的接口、抽象类和具体类。我们将尝试探讨它们各自是什么,以及何时应该使用它们,以便更好地设计应用程序。
引言
在本文中,我们将详细介绍 C# 中的接口、抽象类和具体类。我们将尝试理解它们各自是什么,以及何时应该使用接口、抽象类和具体类来更好地设计应用程序。
背景
我时不时会在 CodeProject 的问答区和其他论坛上看到关于“接口、抽象类和具体类”的问题。大多数时候,提问者都是初学者。之所以这些问题反复出现,是因为之前问题的答案(很多都是非常好的答案)会随着时间和问题数量的增加而淹没。因此,我构思了这篇小文章,涵盖了一些与这些主题相关的要点,以便初学者可以将它作为一个参考点来理解接口、抽象类和具体类。
Using the Code
让我们从“是什么”开始讨论。一旦我们理解了这一点,我们就可以在讨论的后面部分深入探讨“为什么”和“何时”。
什么是具体类
具体类,或者简单地说类,是我们用来指定任何有意义实体的语言构造。有意义的实体可以被认为是任何需要在我们的应用程序中表示的现实世界实体或业务实体。从另一个角度来看,类就像一个蓝图。一个蓝图用来表示所有具有相同属性和行为的对象。让我们以 Student
类为例。
class Student
{
DateTime dateOfBirth;
public DateTime DateOfBirth
{
get { return dateOfBirth; }
set { dateOfBirth = value; }
}
string firstName;
public string FirstName
{
get { return firstName; }
set { firstName = value; }
}
string lastName;
public string LastName
{
get { return lastName; }
set { lastName = value; }
}
string enrollmentNumber;
public string EnrollmentNumber
{
get { return enrollmentNumber; }
set { enrollmentNumber = value; }
}
public int GetAge(DateTime currentDate)
{
return currentDate.Year - dateOfBirth.Year;
}
}
这个 Student
类包含四个作为属性公开的属性。它有一个单一的行为,即在给定日期检索学生(以年为单位)的年龄。该类还包含四个成员变量,用作这四个属性的后端字段。在较新版本的 C# 中,如果属性中没有实现逻辑,我们可以完全省略后端字段。我们可以通过将这些属性实现为自动属性来实现这一点。
class Student
{
public DateTime DateOfBirth { get; set; }
public string FirstName { get; set; }
public string LastName { get; set; }
public string EnrollmentNumber { get; set; }
public int GetAge(DateTime currentDate)
{
return currentDate.Year - DateOfBirth.Year;
}
}
关于抽象和封装的说明
当我们谈论设计类时,我们需要考虑两件主要事情。第一件事是“这个类将向应用程序/世界的其余部分暴露什么?”即 public
方法和属性。第二件事是“我们将如何实现这些暴露的属性和方法?”
抽象主要处理“这个类将向应用程序的其余部分暴露什么?”。决定将由应用程序其余部分使用的类的公共接口称为抽象。另一方面,封装处理“我们将如何实现这些暴露的属性和方法?”。这包括我们编写在属性和方法中的任何代码。这些代码实际上对该类的用户隐藏了。这就是封装。
所以,在某种程度上,我们说的是暴露的 public
属性和方法是类提供的抽象。属性和方法的内部实现细节被隐藏起来,并封装在类的内部实现中。
什么是抽象类
在上面的代码中,我们看到 Student
类能够实现其所有行为,即所有方法。如果我们有一个不知道如何实现所有行为的类怎么办?假设我们的 student
类还需要提供一个 GetFee()
函数。但这个类不知道如何实现它,因为费用将取决于 student
的类型(假设 student
可以有多种类型,即 Regular
和 Online
)。所以为了表示这一点,让我们更改 student
类,使其包含一个没有实现的方法。
abstract class AStudent
{
public DateTime DateOfBirth { get; set; }
public string FirstName { get; set; }
public string LastName { get; set; }
public string EnrollmentNumber { get; set; }
public int GetAge(DateTime currentDate)
{
return currentDate.Year - DateOfBirth.Year;
}
public abstract double GetFee();
}
现在,由于 GetFee()
方法没有任何实现,我们需要将其标记为 abstract
。由于我们的一个函数是 abstract
,我们需要将类标记为 abstract
。这实际上说明,该类定义了一个 public
接口,即一个抽象,但它不一定带有所有函数的实现。因此,如果有人需要继承该类,他们需要为所有被标记为 abstract
方法的方法提供实现。
我们不能直接使用 abstract
类,因为将一个类标记为 abstract
表明该类旨在作为所需的抽象(可以说是契约),任何想要继承它的类都必须提供 abstract
方法的实现。
class RegularStudent : AStudent
{
public override Double GetFee()
{
// fetch from some database and return
return 10000f;
}
}
所以,为了使用 abstract
类,我创建了一个 Concrete
类 RegularStudent
。该类公开了与 AStudent
相同的抽象,因为它继承自 Astudent
,但它也为 AStudent
的 abstract
方法提供了实现。
现在,让我们将 abstract
类的概念推向极端,设想一个所有方法都被标记为 abstract
的类,即一个纯 abstract
类。
abstract class AShape
{
public abstract double GetArea();
public abstract double GetPerimeter();
}
现在这是一个纯 abstract
类。这意味着,该类仅提供应提供的抽象,并且没有任何实现。因此,将从该类派生的类必须为该类的所有方法提供实现。
在讨论纯 abstract
类之前,让我们先看看接口,然后我们就能更好地理解所有概念了。
什么是接口
接口是一种语言构造,允许我们在其中定义一组方法和属性。任何实现该接口的类都必须提供该接口的实现。这看起来非常类似于纯 abstract
类的概念,但在我们深入研究之前,让我们尝试为我们的形状问题创建一个接口。
public interface IShape
{
double GetArea();
double GetPerimeter();
}
这个接口的含义是,它只提供了抽象的定义,即一个所有实现者都应提供的契约。因此,为了实现接口,我们需要为具体类提供接口的实现。
public class Square : IShape
{
int _sideLength;
public Square(int sideLength)
{
this._sideLength = sideLength;
}
public double GetArea()
{
return _sideLength * _sideLength;
}
public double GetPerimeter()
{
return 4 * _sideLength;
}
}
所以 Square
类现在实现了 IShape
,并为 IShape
接口提供了实现。接口的所有成员默认都是 public
的,即我们不需要在接口定义中提供访问修饰符。
为什么我们需要接口和抽象类
现在我们知道了类、抽象类和接口的“是什么”。现在是时候理解与 abstract
类和接口相关的“为什么”了。让我们尝试一步一步地理解它。
接口和 abstract
类是强制具体类遵守契约的一种方式。所以,如果
- 我们需要多个类以多态方式运行。
- 我们需要某种契约来强制实施类,则将契约放在接口中。
现在主要的问题是接口和纯 abstract
类之间的选择,因为两者都可以用来强制执行对具体类的契约,我们应该何时使用哪一个。让我们看一些可以帮助我们回答这个问题的一些要点。
- 接口可以由类实现,也可以由结构(值类型,而非引用类型)实现。而
abstract
类只能由类,即引用类型继承。 - 基于接口的多态性比基于基类的多态性更灵活。如果我们使用基于接口的多态性,具体类型可以是值类型或引用类型。
- 实现接口更灵活,即一个类只能有一个直接基类,但它可以实现多个接口。这意味着我们的具体类可以通过实现多个接口来遵守多个契约。如果我们的契约被指定为
abstract
类而不是接口,这是不可能的。
何时应使用接口和抽象类
接口:当我们只需要强制执行契约时。任何实现该接口的人都将提供方法的实现。
抽象类:当我们需要的基类型知道如何实现抽象的一部分,即部分实现,而抽象的另一部分/其余部分不能由该类实现时。它将实现剩余方法的责任留给派生自该类的类。
为了说明这一点,让我们回顾一下 Shape
问题。IShape
接口只是一个契约,它表明任何实现它的人都必须为 GetArea
和 GetPerimeter
函数提供实现。
public interface IShape
{
double GetArea();
double GetPerimeter();
}
我们可以有一个 Circle
类,类似于上面的 Square
类,它可以实现 IShape
接口。
public class Circle : IShape
{
int _radius;
public Circle(int radius)
{
this._radius = radius;
}
public double GetArea()
{
return Math.PI * _radius * _radius;
}
public double GetPerimeter()
{
return 2 * _radius * Math.PI;
}
}
现在,当需要一个像 Quadrilateral
这样的类时,我们可以将其设为 abstract
类,因为我们可以通过对所有边求和来安全地计算周长,但我们无法计算面积,除非知道了 Quadrilateral
的类型(数学上是可能的,这只是设计我们的 abstract
类的一个假设场景)。
public abstract class AQuadrilateral : IShape
{
public int Side1 { get; private set; }
public int Side2 { get; private set; }
public int Side3 { get; private set; }
public int Side4 { get; private set; }
public AQuadrilateral(int side1, int side2, int side3, int side4)
{
Side1 = side1;
Side2 = side2;
Side3 = side3;
Side4 = side4;
}
public abstract double GetArea();
public double GetPerimeter()
{
return Side1 + Side2 + Side3 + Side4;
}
}
所以这个类仍然实现了 IShape
,因为它承诺履行契约,然后它将自己定义为 abstract
,以表明契约只得到了部分履行。如果我们想使用这个类,我们需要实现契约的剩余部分。让我们创建一个简单的 Rectangle
类来看看如何做到。
public class Rectangle : AQuadrilateral
{
public int Width { get; private set; }
public int Height { get; private set; }
public Rectangle(int width, int height)
: base(side1: width, side2: height, side3: width, side4: height)
{
Width = width;
Height = height;
}
public override double GetArea()
{
return Width * Height;
}
}
我们可以看到,在 Rectangle
类中,它只需要实现 IShape
指定的、未被 abstract
类实现的契约的剩余部分。Rectangle
类的用户应该仍然通过 IShape
句柄来工作。该句柄将仅仅指向 Rectangle
类的具体实现。
面向接口编程,而非实现
既然我们已经讨论了接口、abstract
类和类,我们可以将讨论总结如下:
- 具体类是实际的实现。
- 接口是契约,它指定了实现者应实现的抽象契约。
Abstract
类是两者的折衷,即当我们想要部分实现契约时,我们可以使用abstract
类。
但我们为什么要关心抽象和契约呢?为什么我们不能直接移除接口和 abstract
类,而总是使用具体类?反正我们可以自由地为我们的具体类创建任意数量的版本。嗯,要找到这个问题的答案,我们需要看一个最佳实践:“面向接口编程,而非实现”。
这个最佳实践说,应用程序应该始终从应用程序的其他部分使用接口,而不是具体实现。这种方法有几个好处,如可维护性、可扩展性和可测试性。任何人都可以编写代码来使应用程序运行,但区别在于这些代码是否可维护、可扩展和可测试。让我们详细看看这三个概念,并理解为什么面向接口编程总是好的。
让我们考虑一个简单的组件,它为我们提供了一个 Logger
类。
class LoggerBad
{
public void LogMessage(string msg)
{
// Log this message here
}
public string[] GetLast10Messages()
{
return new string[]
{
"1", "2", "3", "4", "5", "6", "7", "8", "9", "10"
};
}
}
现在我们需要使用这个类来记录所有错误消息。管理控制台也使用这个类来获取最近的 10 条日志消息。所以,类的用户会做类似这样的事情:
class SomeClass
{
public void SomeAction()
{
// lets use the bad logger here
LoggerBad logger = new LoggerBad();
string[] logs = logger.GetLast10Messages();
foreach (string s in logs)
{
Console.WriteLine(s);
}
}
}
这里有两个问题。第一个问题存在于 Logger
组件端,即它只提供了具体类,而不是抽象契约的接口。第二个问题存在于使用端。用户代码实际上在使用具体的类句柄(由于没有接口无法避免),但它也使用了实现,即 string[]
作为 GetLast10Messages
函数的返回类型。
现在,让我们看看如何使用接口并创建一个可维护、可扩展和可测试的应用程序。
可维护性
假设由于某些原因,Logger
类的内部实现已更改,并且 GetLast10Messages
函数现在返回 List<string>
而不是 string[]
。一旦我们采用这个更改后的代码,我们就需要在每次调用 GetLast10Messages
方法的地方进行代码更改(可能在成千上万个地方)。所以可以肯定地说,应用程序是不可维护的。如果我们希望应用程序可维护,那么我们应该面向接口编程,即我们从 GetLast10Messages
接收的数据的唯一用途是在 foreach
循环中使用。现在,而不是面向实现,即 string[]
,我们应该使用 IEnumerable
接口,它就会起作用。而且,当函数开始返回 List
而不是数组时,它也会起作用,因为我们是面向接口而非实现来编程的。
所以,使用 LoggerBad
类的代码应该看起来像
class SomeClass
{
public void SomeAction()
{
// let's use the bad logger here
LoggerBad logger = new LoggerBad();
IEnumerable< string> logs = logger.GetLast10Messages();
foreach (string s in logs)
{
Console.WriteLine(s);
}
}
}
现在,只要返回的值实现了 IEnumerable
接口,返回类型和方法的内部实现就不会影响使用应用程序。从而使我们的应用程序更具可维护性。
可扩展性
现在我们只解决了一部分问题。如果我们是组件的设计者,那么我们就应该为 Logger
类指定一个接口,并以这种方式实现我们的组件。
interface ILogger
{
void LogMessage(string msg);
IEnumerable< string> GetLast10Messages();
}
class LoggerGood : ILogger
{
public void LogMessage(string msg)
{
// Log this message here
}
public IEnumerable< string> GetLast10Messages()
{
return new string[]
{
"1", "2", "3", "4", "5", "6", "7", "8", "9", "10"
};
}
}
这样做的优点是我们的组件是完全可扩展的。如果我们想添加一个类 LogToCloud
,我们只需实现契约,我们的新类就可以从应用程序中消费。
class LogToClound : ILogger
{
public void LogMessage(string msg)
{
// Log this message to the cloud using some web service
}
public IEnumerable< string> GetLast10Messages()
{
// call some web service and fetch the data
return new string[]
{
"1", "2", "3", "4", "5", "6", "7", "8", "9", "10"
};
}
}
通过包含一个接口,我们的组件变得可扩展。但让我们再次看看使用。
class SomeClass
{
public void SomeAction()
{
// let's use the bad logger here
LoggerBad logger = new LoggerBad();
IEnumerable< string> logs = logger.GetLast10Messages();
foreach (string s in logs)
{
Console.WriteLine(s);
}
}
}
仍然存在一个主要问题,那就是用户类仍然面向实现,即 LoggerBad
,而不是接口。如果我想选择性地使用 LogToCloud
,那是不可能的。我们需要解决这个问题。为了解决这个问题,让我们面向接口编程,而不是面向实现。
class SomeClassTwo
{
public void SomeAction(ILogger _logger)
{
ILogger logger = _logger;
IEnumerable< string> logs = logger.GetLast10Messages();
foreach (string s in logs)
{
Console.WriteLine(s);
}
}
}
现在这个类是面向接口编程,而不是面向实现。如果我们想使用 LogToCloud
,我们只需传递它的具体类实例,它就会使用 LogToCloud
。如果我们想使用我们旧的 LoggerGood
,我们可以从构造函数传递它的具体实例,这个类就会使用它。
SomeClassTwo userclass2 = new SomeClassTwo();
// use our good old class
userclass2.SomeAction(new LoggerGood());
// Use the new cloud logger
userclass2.SomeAction(new LogToCloud());
可测试性
拥有单元测试应用程序也意味着大量使用接口而不是实现。这是必需的,因为然后我们可以在单元测试我们的组件时传递 MOCK 类。
实现方式如下:
using
类将拥有应用程序其他部分的接口句柄,并使用它们来执行所有操作。它不会依赖于具体类。- 具体类的实际对象将从外部传递给该类(依赖注入[^])模块。
- 我们的测试项目将传递另一个具体对象,该对象是一个模拟实际功能的类,并实现相同的接口(契约),从而使我们的类在从测试项目调用时使用模拟对象。
使用接口创建单元测试应用程序本身就是一个大话题,因此我在这里不讨论它,但要获取更多信息,请参考:使用 ASP.NET MVC 创建可单元测试的应用程序 - 初学者教程[^]
关注点
在本文中,我们讨论了具体类、抽象类和接口。我们看到了何时应该使用抽象类以及何时应该使用接口。我们看到了接口如何用于提供抽象契约。我们还看到了接口如何使我们能够创建可维护、可扩展和可测试的应用程序。本文是从初学者的角度撰写的。撰写本文的动机之一是 CodeProject 和其他论坛上经常出现的关于接口和抽象类的问题。我希望本文具有一定的启发性。
历史
- 2013年12月26日:初稿