Samouczek: eksplorowanie podstawowych konstruktorów

Język C# 12 wprowadza konstruktory podstawowe, zwięzłą składnię do deklarowania konstruktorów, których parametry są dostępne w dowolnym miejscu w treści typu.

Z tego samouczka dowiesz się:

  • Kiedy zadeklarować konstruktor podstawowy w typie
  • Jak wywoływać konstruktory podstawowe z innych konstruktorów
  • Jak używać podstawowych parametrów konstruktora w elementach członkowskich typu
  • Gdzie są przechowywane podstawowe parametry konstruktora

Wymagania wstępne

Musisz skonfigurować maszynę do uruchamiania platformy .NET 8 lub nowszej, w tym kompilatora C# 12 lub nowszego. Kompilator języka C# 12 jest dostępny od programu Visual Studio 2022 w wersji 17.7 lub zestawu .NET 8 SDK.

Konstruktory podstawowe

Parametry można dodać do struct deklaracji lub class w celu utworzenia konstruktora podstawowego. Podstawowe parametry konstruktora są w zakresie w całej definicji klasy. Ważne jest, aby wyświetlić podstawowe parametry konstruktora jako parametry , mimo że są one w zakresie w całej definicji klasy. Kilka reguł wyjaśnia, że są to parametry:

  1. Podstawowe parametry konstruktora mogą nie być przechowywane, jeśli nie są potrzebne.
  2. Podstawowe parametry konstruktora nie są elementami członkowskimi klasy. Na przykład podstawowy parametr konstruktora o nazwie param nie może być dostępny jako this.param.
  3. Do podstawowego konstruktora można przypisać parametry.
  4. Podstawowe parametry konstruktora nie stają się właściwościami, z wyjątkiem record typów.

Te reguły są takie same jak parametry dowolnej metody, w tym inne deklaracje konstruktorów.

Najczęstsze zastosowania dla podstawowego parametru konstruktora to:

  1. Jako argument wywołania konstruktora base() .
  2. Aby zainicjować pole lub właściwość elementu członkowskiego.
  3. Odwoływanie się do parametru konstruktora w elemencie członkowskim wystąpienia.

Każdy inny konstruktor klasy musi wywoływać konstruktor podstawowy bezpośrednio lub pośrednio za pomocą wywołania konstruktora this() . Ta reguła zapewnia, że podstawowe parametry konstruktora są przypisywane w dowolnym miejscu w treści typu.

Inicjowanie właściwości

Poniższy kod inicjuje dwie właściwości odczytu, które są obliczane na podstawie podstawowych parametrów konstruktora:

public readonly struct Distance(double dx, double dy)
{
    public readonly double Magnitude { get; } = Math.Sqrt(dx * dx + dy * dy);
    public readonly double Direction { get; } = Math.Atan2(dy, dx);
}

Powyższy kod demonstruje podstawowy konstruktor używany do inicjowania właściwości readonly obliczanych. Inicjatory pól i MagnitudeDirection używają podstawowych parametrów konstruktora. Podstawowe parametry konstruktora nie są używane nigdzie indziej w struktury. Poprzednia struktura jest taka, jakby została napisana następujący kod:

public readonly struct Distance
{
    public readonly double Magnitude { get; }

    public readonly double Direction { get; }

    public Distance(double dx, double dy)
    {
        Magnitude = Math.Sqrt(dx * dx + dy * dy);
        Direction = Math.Atan2(dy, dx);
    }
}

Nowa funkcja ułatwia używanie inicjatorów pól, gdy potrzebne są argumenty do inicjowania pola lub właściwości.

Tworzenie stanu modyfikowalnego

Powyższe przykłady używają podstawowych parametrów konstruktora do inicjowania właściwości tylko do odczytu. Można również używać konstruktorów podstawowych, gdy właściwości nie są tylko do odczytu. Spójrzmy na poniższy kod:

public struct Distance(double dx, double dy)
{
    public readonly double Magnitude => Math.Sqrt(dx * dx + dy * dy);
    public readonly double Direction => Math.Atan2(dy, dx);

    public void Translate(double deltaX, double deltaY)
    {
        dx += deltaX;
        dy += deltaY;
    }

    public Distance() : this(0,0) { }
}

W poprzednim przykładzie Translate metoda zmienia składniki dx i dy . Magnitude Wymaga to obliczenia właściwości i Direction podczas uzyskiwania dostępu. Operator => wyznacza metodę dostępu do wyrażeń get , natomiast = operator wyznacza inicjator. Ta wersja dodaje konstruktor bez parametrów do struktury. Konstruktor bez parametrów musi wywołać konstruktor podstawowy, aby wszystkie podstawowe parametry konstruktora zostały zainicjowane.

W poprzednim przykładzie dostęp do podstawowych właściwości konstruktora jest uzyskiwany w metodzie . W związku z tym kompilator tworzy ukryte pola reprezentujące każdy parametr. Poniższy kod pokazuje około tego, co generuje kompilator. Rzeczywiste nazwy pól są prawidłowymi identyfikatorami CIL, ale nie są prawidłowymi identyfikatorami języka C#.

public struct Distance
{
    private double __unspeakable_dx;
    private double __unspeakable_dy;

    public readonly double Magnitude => Math.Sqrt(__unspeakable_dx * __unspeakable_dx + __unspeakable_dy * __unspeakable_dy);
    public readonly double Direction => Math.Atan2(__unspeakable_dy, __unspeakable_dx);

    public void Translate(double deltaX, double deltaY)
    {
        __unspeakable_dx += deltaX;
        __unspeakable_dy += deltaY;
    }

    public Distance(double dx, double dy)
    {
        __unspeakable_dx = dx;
        __unspeakable_dy = dy;
    }
    public Distance() : this(0, 0) { }
}

Ważne jest, aby zrozumieć, że pierwszy przykład nie wymagał od kompilatora utworzenia pola do przechowywania wartości parametrów konstruktora podstawowego. W drugim przykładzie użyto podstawowego parametru konstruktora wewnątrz metody i w związku z tym kompilator musiał utworzyć dla nich magazyn. Kompilator tworzy magazyn dla wszystkich konstruktorów podstawowych tylko wtedy, gdy ten parametr jest dostępny w treści elementu członkowskiego typu. W przeciwnym razie podstawowe parametry konstruktora nie są przechowywane w obiekcie.

Wstrzykiwanie zależności

Innym typowym zastosowaniem dla konstruktorów podstawowych jest określenie parametrów iniekcji zależności. Poniższy kod tworzy prosty kontroler, który wymaga interfejsu usługi do użycia:

public interface IService
{
    Distance GetDistance();
}

public class ExampleController(IService service) : ControllerBase
{
    [HttpGet]
    public ActionResult<Distance> Get()
    {
        return service.GetDistance();
    }
}

Konstruktor podstawowy wyraźnie wskazuje parametry wymagane w klasie. Podstawowe parametry konstruktora są używane tak, jak każda inna zmienna w klasie.

Inicjowanie klasy bazowej

Można wywołać podstawowy konstruktor klasy bazowej z konstruktora podstawowego klasy pochodnej. Najłatwiej jest napisać klasę pochodną, która musi wywołać podstawowy konstruktor w klasie bazowej. Rozważmy na przykład hierarchię klas reprezentujących różne typy kont jako bank. Klasa bazowa będzie wyglądać podobnie do następującego kodu:

public class BankAccount(string accountID, string owner)
{
    public string AccountID { get; } = accountID;
    public string Owner { get; } = owner;

    public override string ToString() => $"Account ID: {AccountID}, Owner: {Owner}";
}

Wszystkie konta bankowe, niezależnie od typu, mają właściwości numeru konta i właściciela. W ukończonej aplikacji do klasy bazowej zostaną dodane inne typowe funkcje.

Wiele typów wymaga bardziej szczegółowej weryfikacji parametrów konstruktora. Na przykład parametr BankAccount ma określone wymagania dotyczące owner parametrów i accountID : Nie owner może zawierać null znaków lub białych znaków, a accountID ciąg musi zawierać 10 cyfr. Tę walidację można dodać podczas przypisywania odpowiednich właściwości:

public class BankAccount(string accountID, string owner)
{
    public string AccountID { get; } = ValidAccountNumber(accountID) 
        ? accountID 
        : throw new ArgumentException("Invalid account number", nameof(accountID));

    public string Owner { get; } = string.IsNullOrWhiteSpace(owner) 
        ? throw new ArgumentException("Owner name cannot be empty", nameof(owner)) 
        : owner;

    public override string ToString() => $"Account ID: {AccountID}, Owner: {Owner}";

    public static bool ValidAccountNumber(string accountID) => 
    accountID?.Length == 10 && accountID.All(c => char.IsDigit(c));
}

W poprzednim przykładzie pokazano, jak można zweryfikować parametry konstruktora przed przypisaniem ich do właściwości. Możesz użyć wbudowanych metod, takich jak String.IsNullOrWhiteSpace(String), lub własnej metody weryfikacji, takich jak ValidAccountNumber. W poprzednim przykładzie wszystkie wyjątki są zgłaszane z konstruktora, gdy wywołuje inicjatory. Jeśli parametr konstruktora nie jest używany do przypisywania pola, podczas pierwszego uzyskiwania dostępu do parametru konstruktora są zgłaszane wyjątki.

Jedna klasa pochodna przedstawiałaby konto kontrolne:

public class CheckingAccount(string accountID, string owner, decimal overdraftLimit = 0) : BankAccount(accountID, owner)
{
    public decimal CurrentBalance { get; private set; } = 0;

    public void Deposit(decimal amount)
    {
        if (amount < 0)
        {
            throw new ArgumentOutOfRangeException(nameof(amount), "Deposit amount must be positive");
        }
        CurrentBalance += amount;
    }

    public void Withdrawal(decimal amount)
    {
        if (amount < 0)
        {
            throw new ArgumentOutOfRangeException(nameof(amount), "Withdrawal amount must be positive");
        }
        if (CurrentBalance - amount < -overdraftLimit)
        {
            throw new InvalidOperationException("Insufficient funds for withdrawal");
        }
        CurrentBalance -= amount;
    }
    
    public override string ToString() => $"Account ID: {AccountID}, Owner: {Owner}, Balance: {CurrentBalance}";
}

Klasa pochodna CheckingAccount ma podstawowy konstruktor, który przyjmuje wszystkie parametry wymagane w klasie bazowej, a inny parametr z wartością domyślną. Podstawowy konstruktor wywołuje konstruktor podstawowy przy użyciu : BankAccount(accountID, owner) składni . To wyrażenie określa zarówno typ klasy bazowej, jak i argumenty dla konstruktora podstawowego.

Klasa pochodna nie jest wymagana do używania konstruktora podstawowego. Konstruktor w klasie pochodnej, który wywołuje konstruktor podstawowy klasy bazowej, jak pokazano w poniższym przykładzie:

public class LineOfCreditAccount : BankAccount
{
    private readonly decimal _creditLimit;
    public LineOfCreditAccount(string accountID, string owner, decimal creditLimit) : base(accountID, owner)
    {
        _creditLimit = creditLimit;
    }
    public decimal CurrentBalance { get; private set; } = 0;

    public void Deposit(decimal amount)
    {
        if (amount < 0)
        {
            throw new ArgumentOutOfRangeException(nameof(amount), "Deposit amount must be positive");
        }
        CurrentBalance += amount;
    }

    public void Withdrawal(decimal amount)
    {
        if (amount < 0)
        {
            throw new ArgumentOutOfRangeException(nameof(amount), "Withdrawal amount must be positive");
        }
        if (CurrentBalance - amount < -_creditLimit)
        {
            throw new InvalidOperationException("Insufficient funds for withdrawal");
        }
        CurrentBalance -= amount;
    }

    public override string ToString() => $"{base.ToString()}, Balance: {CurrentBalance}";
}

Istnieje jeden potencjalny problem z hierarchiami klas i konstruktorami podstawowymi: istnieje możliwość utworzenia wielu kopii podstawowego parametru konstruktora, ponieważ jest on używany zarówno w klasach pochodnych, jak i podstawowych. Poniższy przykład kodu tworzy dwie kopie każdego z owner pól i accountID :

public class SavingsAccount(string accountID, string owner, decimal interestRate) : BankAccount(accountID, owner)
{
    public SavingsAccount() : this("default", "default", 0.01m) { }
    public decimal CurrentBalance { get; private set; } = 0;

    public void Deposit(decimal amount)
    {
        if (amount < 0)
        {
            throw new ArgumentOutOfRangeException(nameof(amount), "Deposit amount must be positive");
        }
        CurrentBalance += amount;
    }

    public void Withdrawal(decimal amount)
    {
        if (amount < 0)
        {
            throw new ArgumentOutOfRangeException(nameof(amount), "Withdrawal amount must be positive");
        }
        if (CurrentBalance - amount < 0)
        {
            throw new InvalidOperationException("Insufficient funds for withdrawal");
        }
        CurrentBalance -= amount;
    }

    public void ApplyInterest()
    {
        CurrentBalance *= 1 + interestRate;
    }

    public override string ToString() => $"Account ID: {accountID}, Owner: {owner}, Balance: {CurrentBalance}";
}

Wyróżniony wiersz pokazuje, że ToString metoda używa podstawowych parametrów konstruktora (owner i accountID) zamiast właściwości klasy bazowej (Owner i AccountID). Wynikiem jest utworzenie magazynu dla tych kopii przez klasę SavingsAccount pochodną. Kopia w klasie pochodnej różni się od właściwości w klasie bazowej. Jeśli właściwość klasy bazowej może zostać zmodyfikowana, wystąpienie klasy pochodnej nie zobaczy tej modyfikacji. Kompilator wystawia ostrzeżenie dla podstawowych parametrów konstruktora, które są używane w klasie pochodnej i przekazywane do konstruktora klasy bazowej. W tym przypadku poprawka polega na użyciu właściwości klasy bazowej.

Podsumowanie

Możesz użyć konstruktorów podstawowych najlepiej dopasowanych do projektu. W przypadku klas i struktur podstawowe parametry konstruktora są parametrami konstruktora, który należy wywołać. Można ich użyć do inicjowania właściwości. Można zainicjować pola. Te właściwości lub pola mogą być niezmienne lub modyfikowalne. Można ich używać w metodach. Są to parametry i używasz ich w jaki sposób najlepiej pasuje do twojego projektu. Więcej informacji na temat konstruktorów podstawowych można dowiedzieć się w artykule dotyczącym konstruktorów wystąpień i proponowanej podstawowej specyfikacji konstruktora.