Объектно-ориентированное программирование (C#)

C# — это объектно-ориентированный язык программирования. Четыре основных принципа объектно-ориентированного программирования следующие.

  • Абстракция. Моделирование требуемых атрибутов и взаимодействий сущностей в виде классов для определения абстрактного представления системы.
  • Инкапсуляция. Скрытие внутреннего состояния и функций объекта и предоставление доступа только через открытый набор функций.
  • Наследование. Возможность создания новых абстракций на основе существующих.
  • Полиморфизм. Возможность реализации наследуемых свойств или методов отличающимися способами в рамках множества абстракций.

Из предыдущего руководства с общими сведениями о классах вы узнали об абстракции и инкапсуляции. Абстракция банковского счета реализована с помощью класса BankAccount. Эту реализацию можно изменить, не влияя на код, в котором использовался класс BankAccount. Классы BankAccount и Transaction позволяют реализовать инкапсуляцию компонентов, требуемых для описания этих концепций в коде.

В этом руководстве показано, как расширить приложение, чтобы использовать наследование и полиморфизм для добавления новых функций. Вы также добавите компоненты в класс 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.
    • Будет взиматься комиссия за каждый вывод средств, превышающий кредитный лимит.
  • Учетная запись карта подарка:
    • Может пополняться на указанную сумму в последний день каждого месяца.

Как видите, для каждого из этих типов счетов предусмотрено действие, которое выполняется в конце каждого месяца, и эти действия отличаются. Для реализации этого кода используется полиморфизм. Перейдите к методу virtual в классе BankAccount:

public virtual void PerformMonthEndTransactions() { }

В приведенном выше коде показано, как использовать ключевое слово virtual для объявления метода в базовом классе, для которого производный класс может предоставить другую реализацию. virtual определяет метод, в котором любой производный класс может использовать повторную реализацию. Производные классы используют ключевое слово override для определения новой реализации. Обычно это называется переопределением реализации базового класса. Ключевое слово virtual указывает, что производные классы могут переопределить поведение. Вы также можете объявить методы abstract, где производные классы должны переопределять поведение. Базовый класс не предоставляет реализацию для метода abstract. Далее необходимо определить реализацию для двух новых классов, которые вы создали. Начните с InterestEarningAccount:

public override void PerformMonthEndTransactions()
{
    if (Balance > 500m)
    {
        decimal interest = Balance * 0.02m;
        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, чтобы проверить эти изменения для GiftCardAccount и InterestEarningAccount:

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. Это означает, что значение нельзя изменить после создания объекта. После создания BankAccountminimumBalance не может измениться. Во вторых, конструктор, принимающий два параметра, использует : this(name, initialBalance, 0) { } в качестве реализации. Выражение : this() вызывает другой конструктор, который имеет три параметра. Этот метод позволяет применить одну реализацию для инициализации объекта, даже несмотря на то, что клиентский код может выбрать один из многих конструкторов.

Эта реализация вызывает MakeDeposit, только если исходный баланс превышает 0. Таким образом, мы придерживаемся правила, согласно которого депозиты должны быть положительными, а кредитный счет открывается с балансом, равным 0.

Теперь, когда класс BankAccount имеет доступное только для чтения поле для определения минимального баланса, последнее изменение заключается в изменении прописанного в коде значения 0 на minimumBalance в методе MakeWithdrawal:

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. Это означает, что комиссия не взимается. Чтобы проверить эти изменения, добавьте следующий код в метод Main в классе Program:

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, которые производные классы могут переопределить для создания определенного поведения для этого типа счета.