Programación orientada a objetos (C#)

C# es un lenguaje de programación orientado a objetos. Los cuatro principios básicos de la programación orientada a objetos son:

  • Abstracción: modelar los atributos e interacciones pertinentes de las entidades como clases para definir una representación abstracta de un sistema.
  • Encapsulación: ocultar el estado interno y la funcionalidad de un objeto y permitir solo el acceso a través de un conjunto público de funciones.
  • Herencia: capacidad de crear nuevas abstracciones basadas en abstracciones existentes.
  • Polimorfismo: capacidad de implementar propiedades o métodos heredados de maneras diferentes en varias abstracciones.

En el tutorial anterior, Introducción a las clases se trató la abstracción y la encapsulación. La clase BankAccount proporcionó una abstracción para el concepto de una cuenta bancaria. Puede modificar su implementación sin que afecte para nada al código que usó la clase BankAccount. Las clases BankAccount y Transaction proporcionan encapsulación de los componentes necesarios para describir esos conceptos en el código.

En este tutorial, ampliará la aplicación para hacer uso de la herencia y el polimorfismo para agregar nuevas características. También agregará características a la clase BankAccount, aprovechando las técnicas de abstracción y encapsulación que aprendió en el tutorial anterior.

Creación de diferentes tipos de cuentas

Después de compilar este programa, recibirá solicitudes para agregarle características. Funciona bien en situaciones en las que solo hay un tipo de cuenta bancaria. Con el tiempo, las necesidades cambian y se solicitan tipos de cuenta relacionados:

  • Una cuenta que devenga intereses que genera beneficios al final de cada mes.
  • Una línea de crédito que puede tener un saldo negativo, pero cuando sea así, se producirá un cargo por intereses cada mes.
  • Una cuenta de tarjeta de regalo de prepago que comienza con un único depósito y solo se puede liquidar. Se puede recargar una vez al principio de cada mes.

Todas estas cuentas diferentes son similares a la clase BankAccount definida en el tutorial anterior. Podría copiar ese código, cambiar el nombre de las clases y realizar modificaciones. Esa técnica funcionaría a corto plazo, pero a la larga supondría más trabajo. Cualquier cambio se copiará en todas las clases afectadas.

En su lugar, puede crear nuevos tipos de cuenta bancaria que hereden métodos y datos de la clase BankAccount creada en el tutorial anterior. Estas clases nuevas pueden extender la clase BankAccount con el comportamiento específico necesario para cada tipo:

public class InterestEarningAccount : BankAccount
{
}

public class LineOfCreditAccount : BankAccount
{
}

public class GiftCardAccount : BankAccount
{
}

Cada una de estas clases hereda el comportamiento compartido de su clase base compartida, la clase BankAccount. Escriba las implementaciones para la funcionalidad nueva y diferente en cada una de las clases derivadas. Estas clases derivadas ya tienen todo el comportamiento definido en la clase BankAccount.

Es recomendable crear cada clase nueva en un archivo de código fuente diferente. En Visual Studio, puede hacer clic con el botón derecho en el proyecto y seleccionar Agregar clase para agregar una clase nueva en un archivo nuevo. En Visual Studio Code, seleccione Archivo y luego Nuevo para crear un nuevo archivo de código fuente. En cualquier herramienta, ponga un nombre al archivo que coincida con la clase: InterestEarningAccount.cs, LineOfCreditAccount.cs y GiftCardAccount.cs.

Cuando cree las clases como se muestra en el ejemplo anterior, observará que ninguna de las clases derivadas se compila. La inicialización de un objeto es responsabilidad de un constructor. Un constructor de clase derivada debe inicializar la clase derivada y proporcionar instrucciones sobre cómo inicializar el objeto de la clase base incluido en la clase derivada. Normalmente, se produce una inicialización correcta sin ningún código adicional. La clase BankAccount declara un constructor público con la siguiente firma:

public BankAccount(string name, decimal initialBalance)

El compilador no genera un constructor predeterminado al definir un constructor. Esto significa que cada clase derivada debe llamar explícitamente a este constructor. Se declara un constructor que puede pasar argumentos al constructor de la clase base. En el código siguiente se muestra el constructor de InterestEarningAccount:

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

Los parámetros de este nuevo constructor coinciden con el tipo de parámetro y los nombres del constructor de clase base. Utilice la sintaxis de : base() para indicar una llamada a un constructor de clase base. Algunas clases definen varios constructores, y esta sintaxis le permite elegir el constructor de clase base al que llama. Una vez que haya actualizado los constructores, puede desarrollar el código para cada una de las clases derivadas. Los requisitos para las clases nuevas se pueden indicar de la siguiente manera:

  • Una cuenta que devenga intereses:
    • obtendrá un crédito del 2 % del saldo a final de mes.
  • Una línea de crédito:
    • puede tener un saldo negativo, pero no mayor en valor absoluto que el límite de crédito.
    • Generará un cargo por intereses cada mes en el que el saldo final del mes no sea 0.
    • Generará un cargo por cada retirada que supere el límite de crédito.
  • Una cuenta de tarjeta regalo:
    • Se puede recargar con una cantidad especificada una vez al mes, el último día del mes.

Puede ver que los tres tipos de cuenta tienen una acción que tiene lugar al final de cada mes. Sin embargo, cada tipo de cuenta realiza diferentes tareas. Utiliza el polimorfismo para implementar este código. Cree un método virtual único en la clase BankAccount:

public virtual void PerformMonthEndTransactions() { }

El código anterior muestra cómo se usa la palabra clave virtual para declarar un método en la clase base para el que una clase derivada puede proporcionar una implementación diferente. Un método virtual es un método en el que cualquier clase derivada puede optar por volver a implementar. Las clases derivadas usan la palabra clave override para definir la nueva implementación. Normalmente, se hace referencia a este proceso como "reemplazar la implementación de la clase base". La palabra clave virtual especifica que las clases derivadas pueden invalidar el comportamiento. También puede declarar métodos abstract en los que las clases derivadas deben reemplazar el comportamiento. La clase base no proporciona una implementación para un método abstract. A continuación, debe definir la implementación de dos de las nuevas clases que ha creado. Empiece por InterestEarningAccount:

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

Agregue el código siguiente a LineOfCreditAccount. El código niega el saldo para calcular un cargo de interés positivo que se retira de la cuenta:

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");
    }
}

La clase GiftCardAccount necesita dos cambios para implementar su funcionalidad de fin de mes. En primer lugar, modifique el constructor para incluir una cantidad opcional para agregar cada mes:

private readonly decimal _monthlyDeposit = 0m;

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

El constructor proporciona un valor predeterminado para el valor monthlyDeposit, por lo que los llamadores pueden omitir 0 para ningún ingreso mensual. A continuación, invalide el método PerformMonthEndTransactions para agregar el depósito mensual, si se estableció en un valor distinto de cero en el constructor:

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

La invalidación aplica el conjunto de depósitos mensual en el constructor. Agregue el código siguiente al método Main para probar estos cambios en GiftCardAccount y en 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());

Verifique los resultados. Ahora, agregue un conjunto similar de código de prueba para 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());

Al agregar el código anterior y ejecutar el programa, verá algo parecido al siguiente error:

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

Nota:

La salida real incluye la ruta de acceso completa a la carpeta con el proyecto. Los nombres de carpeta se omitieron para ser más breves. Además, dependiendo del formato del código, los números de línea pueden ser ligeramente diferentes.

Este código produce un error porque BankAccount supone que el saldo inicial debe ser mayor que 0. Otra suposición incorporada en la clase BankAccount es que el saldo no puede entrar en cifras negativas. Lo que sucede que es se rechazan las retiradas que provocan un descubierto en la cuenta. Ambas suposiciones deben cambiar. La línea de la cuenta de crédito comienza en 0, y generalmente tendrá un saldo negativo. Además, si un cliente retira demasiado dinero, generará un cargo. La transacción se acepta, solo que cuesta más. La primera regla se puede implementar agregando un argumento opcional al constructor BankAccount que especifica el saldo mínimo. El valor predeterminado es 0. La segunda regla requiere un mecanismo que permita que las clases derivadas modifiquen el algoritmo predeterminado. En cierto sentido, la clase base "pregunta" al tipo derivado qué debe ocurrir cuando hay un descubierto. El comportamiento predeterminado es rechazar la transacción generando una excepción.

Comencemos agregando un segundo constructor que incluya un parámetro minimumBalance opcional. Este nuevo constructor se ocupa de todas las acciones que realiza el constructor existente. Además, establece la propiedad del saldo mínimo. Puede copiar el cuerpo del constructor existente, pero eso significa que dos ubicaciones cambiarán en el futuro. Lo que puede hacer es usar un encadenamiento de constructores para que un constructor llame a otro. En el código siguiente se muestran los dos constructores y el nuevo campo adicional:

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");
}

En el código anterior se muestran dos técnicas nuevas. En primer lugar, el campo minimumBalance está marcado como readonly. Esto significa que el valor no se puede cambiar después de que se construya el objeto. Una vez que se crea BankAccount, minimumBalance no puede cambiar. En segundo lugar, el constructor que toma dos parámetros utiliza : this(name, initialBalance, 0) { } como su implementación. La expresión : this() llama al otro constructor, el que tiene tres parámetros. Esta técnica permite tener una única implementación para inicializar un objeto, aunque el código de cliente puede elegir uno de muchos constructores.

Esta implementación solo llama a MakeDeposit si el saldo inicial es mayor que 0. Esto conserva la regla de que los depósitos deben ser positivos, pero permite que la cuenta de crédito se abra con un saldo de 0.

Ahora que la clase BankAccount tiene un campo de solo lectura para el saldo mínimo, el último cambio es modificar la codificación rígida 0 a minimumBalance en el método MakeWithdrawal:

if (Balance - amount < _minimumBalance)

Después de extender la clase BankAccount, puede modificar el constructor LineOfCreditAccount para llamar al nuevo constructor base, como se muestra en el código siguiente:

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

Observe que el constructor LineOfCreditAccount cambia el signo del parámetro creditLimit para que coincida con el significado del parámetro minimumBalance.

Diferentes reglas de descubierto

La última característica que se va a agregar permite a LineOfCreditAccount cobrar una cuota por sobrepasar el límite de crédito en lugar de rechazar la transacción.

Una técnica consiste en definir una función virtual en la que se implemente el comportamiento requerido. La clase BankAccount refactoriza el método MakeWithdrawal en dos métodos. El nuevo método realiza la acción especificada cuando la retirada toma el saldo por debajo del mínimo. El método MakeWithdrawal existente tiene el siguiente código:

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);
}

Reemplácelo por el código siguiente:

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;
    }
}

El método agregado es protected, lo que significa que solo se puede llamar desde clases derivadas. Esa declaración impide que otros clientes llamen al método. También es virtual para que las clases derivadas puedan cambiar el comportamiento. El tipo de valor devuelto es Transaction?. La anotación ? indica que el método puede devolver null. Agregue la siguiente implementación en LineOfCreditAccount para cobrar una cuota cuando se supere el límite de retirada:

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

El reemplazo devuelve una transacción de cuota cuando en la cuenta se produce un descubierto. Si la retirada no supera el límite, el método devuelve una transacción null. Esto indica que no hay ninguna cuota. Pruebe estos cambios agregando el código siguiente al método Main en la clase 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());

Ejecute el programa y compruebe los resultados.

Resumen

Si se ha quedado bloqueado, puede consultar el origen de este tutorial en el repositorio de GitHub.

En este tutorial se han mostrado muchas de las técnicas que se usan en la programación orientada a objetos:

  • Usó la abstracción cuando definió clases para cada uno de los distintos tipos de cuenta. Esas clases describían el comportamiento de ese tipo de cuenta.
  • Usó la encapsulación cuando mantuvo muchos detalles private en cada clase.
  • Usó la herencia cuando aprovechó la implementación ya creada en la clase BankAccount para guardar el código.
  • Usó el polimorfismo cuando creó métodos virtual que las clases derivadas podrían reemplazar para crear un comportamiento específico para ese tipo de cuenta.