面向对象的编程 (C#)

C# 是面向对象的编程语言。 面向对象编程的四项基本原则为:

  • 抽象:将实体的相关特性和交互建模为类,以定义系统的抽象表示。
  • 封装:隐藏对象的内部状态和功能,并仅允许通过一组公共函数进行访问。
  • 继承:根据现有抽象创建新抽象的能力。
  • 多形性:跨多个抽象以不同方式实现继承属性或方法的能力。

在前面的类简介 教程中,我们介绍了抽象和封装。 BankAccount 类提供银行帐户这一概念的抽象。 你可以修改其实现,而不影响使用 BankAccount 类的任何代码。 BankAccountTransaction 类都提供在代码中描述这些概念所需组件的封装。

在本教程中,你将扩展该应用程序以利用继承和多形性来添加新功能。 你还将利用在上一教程中学到的抽象和封装技术,向 BankAccount 类添加功能。

创建不同类型的帐户

生成此程序后,你将获取向其添加功能的请求。 在只有一种银行帐户类型的情况下,其效果良好。 随着时间的推移,需求会发生变化,因而请求了相关的帐户类型:

  • 在每个月的月末获得利息的红利帐户。
  • 余额可以为负,但存在余额时会产生每月利息的信用帐户。
  • 以单笔存款开户且只能用于支付的预付礼品卡帐户。 可在每月初重新充值一次。

所有这些帐户都与前面的教程中定义的 BankAccount 类类似。 你可以复制该代码、重命名类并进行修改。 这种方法在短期内有效,但随着时间的推移,其效果会更为明显。 所有更改都将复制到所有受影响的类。

相反,你可以创建新的银行帐户类型,使其从上一教程中创建的 BankAccount 类继承方法和数据。 这些新类可以用每种类型所需的特定行为来扩展 BankAccount 类:

public class InterestEarningAccount : BankAccount
{
}

public class LineOfCreditAccount : BankAccount
{
}

public class GiftCardAccount : BankAccount
{
}

其中的每个类都从其共享的基类( BankAccount 类)继承共享的行为。 为的每个派生类中的新增和不同功能编写实现。 这些派生类已具有 BankAccount 类中定义的所有行为。

最好在不同的源文件中创建每个新类。 在 Visual Studio中,可以右键单击项目,然后选择“添加类”以在新文件中添加新类。 在 Visual Studio Code 中,选择“文件”,然后选择“新建”以创建新的源文件。 在任一工具中,将文件命名为与类匹配:InterestEarningAccount.cs、LineOfCreditAccount.cs 和 GiftCardAccount.cs。

如前一示例中所示创建类时,你会发现派生类都没有编译。 构造函数负责初始化对象。 派生类构造函数必须初始化派生类,并提供有关如何初始化派生类中所包含基类对象的说明。 一般无需任何额外的代码即可正常初始化。 BankAccount 类声明一个具有以下签名的公共构造函数:

public BankAccount(string name, decimal initialBalance)

当你自行定义构造函数时,编译器不会生成默认构造函数。 这意味着每个派生类必须显式调用此构造函数。 声明可将自变量传递到基类构造函数的构造函数。 下面的代码显示了 InterestEarningAccount 的构造函数:

public InterestEarningAccount(string name, decimal initialBalance) : base(name, initialBalance)
{
}

此新构造函数的参数与基类构造函数的参数类型和名称匹配。 使用 : base() 语法来指示对基类构造函数的调用。 某些类定义多个构造函数,此语法让你可以选取调用的基类构造函数。 更新构造函数后,可以为每个派生类开发代码。 可以这样描述对新类的要求:

  • 红利帐户:
    • 将获得月末余额 2% 的额度。
  • 信用帐户:
    • 余额可以为负,但不能大于信用限额的绝对值。
    • 如果月末余额不为 0,每个月都会产生利息。
    • 将在超过信用限额的每次提款后收取费用。
  • 礼品卡帐户:
    • 每月最后一天,可以充值一次指定的金额。

可以看到,这三种帐户类型都有一个在每月月末发生的操作。 但是,每种帐户类型负责不同的任务。 可以使用多态性来实现此代码。 在 BankAccount 类中创建单个 virtual 方法:

public virtual void PerformMonthEndTransactions() { }

前面的代码演示如何使用 virtual 关键字在基类中声明一个方法,让派生类可以为该方法提供不同的实现。 在 virtual 方法种,任何派生类都可以选择重新实现。 派生类使用 override 关键字定义新的实现。 通常将其称为“重写基类实现”。 virtual 关键字指定派生类可以重写此行为。 还可以声明 abstract 方法,让派生类必须在其中重写此行为。 基类不提供 abstract 方法的实现。 接下来,需要为你创建的两个新类定义实现。 从 InterestEarningAccount 开始:

public override void PerformMonthEndTransactions()
{
    if (Balance > 500m)
    {
        decimal interest = Balance * 0.05m;
        MakeDeposit(interest, DateTime.Now, "apply monthly interest");
    }
}

将以下代码添加到 LineOfCreditAccount。 此代码会取余额的相反数,计算从该帐户提取的正利息:

public override void PerformMonthEndTransactions()
{
    if (Balance < 0)
    {
        // Negate the balance to get a positive interest charge:
        decimal interest = -Balance * 0.07m;
        MakeWithdrawal(interest, DateTime.Now, "Charge monthly interest");
    }
}

GiftCardAccount 类需要通过两项更改来实现其月末功能。 首先,将构造函数修改为包含每个月要充值的可选金额:

private readonly decimal _monthlyDeposit = 0m;

public GiftCardAccount(string name, decimal initialBalance, decimal monthlyDeposit = 0) : base(name, initialBalance)
    => _monthlyDeposit = monthlyDeposit;

构造函数为 monthlyDeposit 值提供默认值,因此调用方可以省略 0 以表示不进行每月存款。 接下来,如果已在构造函数中将 PerformMonthEndTransactions 方法设置为非零值,则重写该方法以添加每月存款:

public override void PerformMonthEndTransactions()
{
    if (_monthlyDeposit != 0)
    {
        MakeDeposit(_monthlyDeposit, DateTime.Now, "Add monthly deposit");
    }
}

重写将在构造函数中应用每月存款设置。 将以下代码添加到 Main 方法,以便为 GiftCardAccountInterestEarningAccount 测试这些更改:

var giftCard = new GiftCardAccount("gift card", 100, 50);
giftCard.MakeWithdrawal(20, DateTime.Now, "get expensive coffee");
giftCard.MakeWithdrawal(50, DateTime.Now, "buy groceries");
giftCard.PerformMonthEndTransactions();
// can make additional deposits:
giftCard.MakeDeposit(27.50m, DateTime.Now, "add some additional spending money");
Console.WriteLine(giftCard.GetAccountHistory());

var savings = new InterestEarningAccount("savings account", 10000);
savings.MakeDeposit(750, DateTime.Now, "save some money");
savings.MakeDeposit(1250, DateTime.Now, "Add more savings");
savings.MakeWithdrawal(250, DateTime.Now, "Needed to pay monthly bills");
savings.PerformMonthEndTransactions();
Console.WriteLine(savings.GetAccountHistory());

验证结果。 现在,为 LineOfCreditAccount 添加一组类似的测试代码:

    var lineOfCredit = new LineOfCreditAccount("line of credit", 0);
    // How much is too much to borrow?
    lineOfCredit.MakeWithdrawal(1000m, DateTime.Now, "Take out monthly advance");
    lineOfCredit.MakeDeposit(50m, DateTime.Now, "Pay back small amount");
    lineOfCredit.MakeWithdrawal(5000m, DateTime.Now, "Emergency funds for repairs");
    lineOfCredit.MakeDeposit(150m, DateTime.Now, "Partial restoration on repairs");
    lineOfCredit.PerformMonthEndTransactions();
    Console.WriteLine(lineOfCredit.GetAccountHistory());

添加前面的代码并运行程序时,你将看到类似于以下错误的内容:

Unhandled exception. System.ArgumentOutOfRangeException: Amount of deposit must be positive (Parameter 'amount')
   at OOProgramming.BankAccount.MakeDeposit(Decimal amount, DateTime date, String note) in BankAccount.cs:line 42
   at OOProgramming.BankAccount..ctor(String name, Decimal initialBalance) in BankAccount.cs:line 31
   at OOProgramming.LineOfCreditAccount..ctor(String name, Decimal initialBalance) in LineOfCreditAccount.cs:line 9
   at OOProgramming.Program.Main(String[] args) in Program.cs:line 29

注意

实际输出包含项目文件夹的完整路径。 为简洁起见,省略了文件夹名称。 此外,根据你的代码格式,行号可能略有不同。

此代码会失败,因为 BankAccount 假设初始余额必须大于 0。 BankAccount 类固有的另一假设是余额不能为负。 相反,将拒绝透支帐户的任何提款。 这两个假设都需要更改。 信用帐户从 0 开始,一般情况下余额为负。 此外,如果客户借款量过大,将产生费用。 会接受该交易,只是会增加费用。 可以通过向 BankAccount 构造函数添加用于指定最小余额的可选参数来实现第一条规则。 默认为 0。 第二条规则需要允许派生类修改默认算法的机制。 在某种意义上,基类会“询问”派生类型在透支时应该怎么做。 默认行为是通过引发异常来拒绝交易。

首先,让我们添加包含可选 minimumBalance 参数的第二个构造函数。 此新构造函数会执行现有构造函数完成的所有操作。 此外,它还会设置最小余额属性。 可以复制现有构造函数的正文,但这意味着将来需要更改两个位置。 相反,可以使用构造函数链接,让一个构造函数调用另一个构造函数。 下面的代码显示了两个构造函数和新的附加字段:

private readonly decimal _minimumBalance;

public BankAccount(string name, decimal initialBalance) : this(name, initialBalance, 0) { }

public BankAccount(string name, decimal initialBalance, decimal minimumBalance)
{
    Number = s_accountNumberSeed.ToString();
    s_accountNumberSeed++;

    Owner = name;
    _minimumBalance = minimumBalance;
    if (initialBalance > 0)
        MakeDeposit(initialBalance, DateTime.Now, "Initial balance");
}

前面的代码演示了两种新方法。 首先,minimumBalance 字段被标记为 readonly。 这意味着构造对象之后不能更改值。 创建 BankAccount 后,minimumBalance 不可更改。 其次,采用两个参数的构造函数使用 : this(name, initialBalance, 0) { } 作为其实现。 : this() 表达式调用另一个构造函数(它具有三个参数)。 即使客户端代码可以从多个构造函数中进行选择,你也可以使用单个实现来初始化对象。

仅当初始余额大于 0 时,此实现才会调用 MakeDeposit。 这将保留存款必须为正的规则,同时允许信用帐户以 0 余额开户。

既然 BankAccount 类具有用于规定最小余额的只读字段,最终更改就是在 MakeWithdrawal 方法中将硬编码的 0 更改为 minimumBalance

if (Balance - amount < minimumBalance)

扩展 BankAccount 类后,可以修改 LineOfCreditAccount 构造函数以调用基构造函数,如以下代码中所示:

public LineOfCreditAccount(string name, decimal initialBalance, decimal creditLimit) : base(name, initialBalance, -creditLimit)
{
}

请注意,LineOfCreditAccount 构造函数会更改 creditLimit 参数的标志,使其与 minimumBalance 参数的意义匹配。

不同的透支规则

要添加的最后一项功能让 LineOfCreditAccount 可以对超过限额的取款进行收费,而不是拒绝交易。

一种方法是定义一个虚函数,并在其中实现所需的行为。 BankAccount 类将 MakeWithdrawal 方法重构为两个方法。 当取款使余额低于最小值时,新方法将执行指定的操作。 现有的 MakeWithdrawal 方法具有以下代码:

public void MakeWithdrawal(decimal amount, DateTime date, string note)
{
    if (amount <= 0)
    {
        throw new ArgumentOutOfRangeException(nameof(amount), "Amount of withdrawal must be positive");
    }
    if (Balance - amount < _minimumBalance)
    {
        throw new InvalidOperationException("Not sufficient funds for this withdrawal");
    }
    var withdrawal = new Transaction(-amount, date, note);
    allTransactions.Add(withdrawal);
}

将其替换为以下代码:

public void MakeWithdrawal(decimal amount, DateTime date, string note)
{
    if (amount <= 0)
    {
        throw new ArgumentOutOfRangeException(nameof(amount), "Amount of withdrawal must be positive");
    }
    Transaction? overdraftTransaction = CheckWithdrawalLimit(Balance - amount < _minimumBalance);
    Transaction? withdrawal = new(-amount, date, note);
    _allTransactions.Add(withdrawal);
    if (overdraftTransaction != null)
        _allTransactions.Add(overdraftTransaction);
}

protected virtual Transaction? CheckWithdrawalLimit(bool isOverdrawn)
{
    if (isOverdrawn)
    {
        throw new InvalidOperationException("Not sufficient funds for this withdrawal");
    }
    else
    {
        return default;
    }
}

添加的方法是 protected,这意味着只能从派生类中调用它。 该声明会阻止其他客户端调用该方法。 它还是 virtual 的,因此派生类可以更改行为。 返回类型为 Transaction?? 批注指示该方法可能返回 null。 在 LineOfCreditAccount 中添加以下实现,以在超过取款限额时收取费用:

protected override Transaction? CheckWithdrawalLimit(bool isOverdrawn) =>
    isOverdrawn
    ? new Transaction(-20, DateTime.Now, "Apply overdraft fee")
    : default;

当帐户透支时,该重写将返回费用交易。 如果取款未超出限额,则该方法将返回 null 交易。 这表明不收取任何费用。 通过将以下代码添加到 Program 类中的 Main 方法来测试这些更改:

var lineOfCredit = new LineOfCreditAccount("line of credit", 0, 2000);
// How much is too much to borrow?
lineOfCredit.MakeWithdrawal(1000m, DateTime.Now, "Take out monthly advance");
lineOfCredit.MakeDeposit(50m, DateTime.Now, "Pay back small amount");
lineOfCredit.MakeWithdrawal(5000m, DateTime.Now, "Emergency funds for repairs");
lineOfCredit.MakeDeposit(150m, DateTime.Now, "Partial restoration on repairs");
lineOfCredit.PerformMonthEndTransactions();
Console.WriteLine(lineOfCredit.GetAccountHistory());

运行该程序,并检查结果。

摘要

如果遇到问题,可以在 GitHub 存储库中查看本教程的源代码。

本教程演示了面向对象的编程中使用的多种技术:

  • 为每个不同的帐户类型定义了类时,你使用了抽象。 这些类描述了该帐户类型的行为。
  • 在每个类中将许多详细信息保留为 private 时,你使用了封装。
  • 使用已在 BankAccount 类中创建的实现来保存代码时,你使用了继承。
  • 创建 virtual 方法,派生类可以重写它们来创建该帐户类型的特定行为时,你使用了多形性。