자습서: 기본 생성자 탐색

C# 12에는 형식 본문 어디에서나 매개 변수를 사용할 수 있는 생성자를 선언하는 간결한 구문인 기본 생성자가 도입되었습니다.

이 자습서에서는 다음에 대해 알아봅니다.

  • 형식에 기본 생성자를 선언해야 하는 경우
  • 다른 생성자에서 기본 생성자를 호출하는 방법
  • 형식의 멤버에서 기본 생성자 매개 변수를 사용하는 방법
  • 기본 생성자 매개 변수가 저장되는 위치

필수 조건

C# 12 이상 컴파일러를 포함하여 .NET 8 이상을 실행하도록 머신을 설정해야 합니다. C# 12 컴파일러는 Visual Studio 2022 버전 17.7 또는 .NET 8 SDK부터 사용할 수 있습니다.

기본 생성자

struct 또는 class 선언에 매개 변수를 추가하여 기본 생성자를 만들 수 있습니다. 기본 생성자 매개 변수는 클래스 정의 전체의 범위에 포함됩니다. 기본 생성자 매개 변수가 클래스 정의 전체의 범위 내에 있더라도 매개 변수로 보는 것이 중요합니다. 여러 규칙에서는 매개 변수임을 명확히 합니다.

  1. 기본 생성자 매개 변수는 필요하지 않은 경우 저장되지 않을 수 있습니다.
  2. 기본 생성자 매개 변수는 클래스의 멤버가 아닙니다. 예를 들어, param이라는 기본 생성자 매개 변수는 this.param으로 액세스할 수 없습니다.
  3. 기본 생성자 매개 변수를 할당할 수 있습니다.
  4. 기본 생성자 매개 변수는 record 형식을 제외하고 속성이 되지 않습니다.

이러한 규칙은 다른 생성자 선언을 포함하여 모든 메서드에 대한 매개 변수와 동일합니다.

기본 생성자 매개 변수의 가장 일반적인 용도는 다음과 같습니다.

  1. base() 생성자 호출에 대한 인수로 사용됩니다.
  2. 멤버 필드 또는 속성을 초기화합니다.
  3. 인스턴스 멤버에서 생성자 매개 변수를 참조하세요.

클래스의 다른 모든 생성자는 반드시this() 생성자 호출을 통해 직접 또는 간접적으로 기본 생성자를 호출해야 합니다. 이 규칙은 기본 생성자 매개 변수가 형식 본문의 어느 위치에나 할당되도록 보장합니다.

속성 초기화

다음 코드는 기본 생성자 매개 변수에서 계산되는 두 개의 읽기 전용 속성을 초기화합니다.

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

앞의 코드는 계산된 읽기 전용 속성을 초기화하는 데 사용되는 기본 생성자를 보여 줍니다. MagnitudeDirection의 필드 이니셜라이저는 기본 생성자 매개 변수를 사용합니다. 기본 생성자 매개 변수는 구조체의 다른 곳에서는 사용되지 않습니다. 이전 구조체는 다음 코드를 작성한 것과 같습니다.

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

새로운 기능을 사용하면 필드나 속성을 초기화하기 위해 인수가 필요할 때 필드 이니셜라이저를 더 쉽게 사용할 수 있습니다.

변경 가능한 상태 만들기

앞의 예에서는 기본 생성자 매개 변수를 사용하여 읽기 전용 속성을 초기화합니다. 속성이 읽기 전용이 아닌 경우 기본 생성자를 사용할 수도 있습니다. 다음 코드를 생각해 봅시다.

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

앞의 예에서 Translate 메서드는 dxdy 구성 요소를 변경합니다. 이를 위해서는 액세스 시 MagnitudeDirection 속성을 계산해야 합니다. => 연산자는 식 본문 get 접근자를 지정하는 반면, = 연산자는 이니셜라이저를 지정합니다. 이 버전은 매개 변수 없는 생성자를 구조체에 추가합니다. 매개 변수가 없는 생성자는 기본 생성자를 호출해야 모든 기본 생성자 매개 변수가 초기화됩니다.

이전 예에서 기본 생성자 속성은 메서드에서 액세스됩니다. 따라서 컴파일러는 각 매개 변수를 나타내기 위해 숨겨진 필드를 만듭니다. 다음 코드는 컴파일러가 생성하는 내용을 대략적으로 보여 줍니다. 실제 필드 이름은 유효한 CIL 식별자이지만 유효한 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) { }
}

첫 번째 예에서는 컴파일러가 기본 생성자 매개 변수의 값을 저장하기 위한 필드를 만들 필요가 없다는 점을 이해해야 합니다. 두 번째 예에서는 메서드 내에서 기본 생성자 매개 변수를 사용했기 때문에 컴파일러에서 해당 매개 변수에 대한 스토리지를 만들어야 했습니다. 컴파일러는 해당 형식의 멤버 본문에서 해당 매개 변수에 액세스할 때만 기본 생성자에 대한 스토리지를 만듭니다. 그렇지 않으면 기본 생성자 매개 변수가 개체에 저장되지 않습니다.

종속성 주입

기본 생성자의 또 다른 일반적인 용도는 종속성 주입을 위한 매개 변수를 지정하는 것입니다. 다음 코드는 사용을 위해 서비스 인터페이스가 필요한 간단한 컨트롤러를 만듭니다.

public interface IService
{
    Distance GetDistance();
}

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

기본 생성자는 클래스에 필요한 매개 변수를 명확하게 나타냅니다. 클래스의 다른 변수와 마찬가지로 기본 생성자 매개 변수를 사용합니다.

기본 클래스 초기화

파생 클래스의 기본 생성자에서 기본 클래스의 기본 생성자를 호출할 수 있습니다. 이는 기본 클래스에서 기본 생성자를 호출해야 하는 파생 클래스를 작성하는 가장 쉬운 방법입니다. 예를 들어, 다양한 계좌 형식을 은행으로 나타내는 클래스 계층을 생각해 보세요. 기본 클래스는 다음 코드와 유사합니다.

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

형식에 관계없이 모든 은행 계좌에는 계좌 번호와 소유자에 대한 속성이 있습니다. 완료된 애플리케이션에서는 다른 공통 기능이 기본 클래스에 추가됩니다.

많은 형식에는 생성자 매개 변수에 대한 보다 구체적인 유효성 검사가 필요합니다. 예를 들어, BankAccount에는 owneraccountID 매개 변수에 대한 특정 요구 사항이 있습니다. ownernull 또는 공백이 아니어야 하고, accountID는 10자리를 포함하는 문자열이어야 합니다. 해당 속성을 할당할 때 이 유효성 검사를 추가할 수 있습니다.

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

이전 예에서는 생성자 매개 변수를 속성에 할당하기 전에 유효성을 검사하는 방법을 보여 줍니다. String.IsNullOrWhiteSpace(String)과 같은 기본 제공 메서드나 ValidAccountNumber와 같은 자체 유효성 검사 메서드를 사용할 수 있습니다. 이전 예에서는 이니셜라이저를 호출할 때 생성자에서 모든 예외가 throw됩니다. 필드를 할당하는 데 생성자 매개 변수를 사용하지 않는 경우 생성자 매개 변수에 처음 액세스할 때 모든 예외가 throw됩니다.

하나의 파생 클래스는 당좌 예금 계좌를 제공합니다.

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

파생된 CheckingAccount 클래스에는 기본 클래스에 필요한 모든 매개 변수를 사용하는 기본 생성자와 기본값이 있는 또 다른 매개 변수가 있습니다. 기본 생성자는 : BankAccount(accountID, owner) 구문을 사용하여 기본 생성자를 호출합니다. 이 식은 기본 클래스의 형식과 기본 생성자의 인수를 모두 지정합니다.

파생 클래스는 기본 생성자를 사용할 필요가 없습니다. 다음 예와 같이 기본 클래스의 기본 생성자를 호출하는 파생 클래스에서 생성자를 만들 수 있습니다.

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

클래스 계층 구문과 기본 생성자에는 한 가지 잠재적인 우려 사항이 있습니다. 즉, 파생 클래스와 기본 클래스 모두에서 사용되는 기본 생성자 매개 변수의 복사본을 여러 개 만들 수 있다는 것입니다. 다음 코드 예에서는 owneraccountID 필드 각각에 두 개의 복사본을 만듭니다.

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

강조 표시된 줄은 ToString 메서드가 기본 클래스 속성(OwnerAccountID) 대신 기본 생성자 매개 변수(owneraccountID)를 사용함을 보여 줍니다. 결과적으로 파생 클래스인 SavingsAccount는 해당 복사본에 대한 스토리지를 만듭니다. 파생 클래스의 복사본은 기본 클래스의 속성과 다릅니다. 기본 클래스 속성을 수정할 수 있는 경우 파생 클래스의 인스턴스에는 해당 수정 사항이 표시되지 않습니다. 컴파일러는 파생 클래스에서 사용되고 기본 클래스 생성자에 전달되는 기본 생성자 매개 변수에 대해 경고를 표시합니다. 이 경우 수정 방법은 기본 클래스의 속성을 사용하는 것입니다.

요약

디자인에 가장 적합한 기본 생성자를 사용할 수 있습니다. 클래스 및 구조체의 경우 기본 생성자 매개 변수는 호출해야 하는 생성자에 대한 매개 변수입니다. 이를 사용하여 속성을 초기화할 수 있습니다. 필드를 초기화할 수 있습니다. 해당 속성이나 필드는 변경할 수 없거나 변경할 수 있습니다. 메서드에서 사용할 수 있습니다. 이는 매개 변수이며 디자인에 가장 적합한 방식으로 사용합니다. 인스턴스 생성자에 대한 C# 프로그래밍 가이드 문서제안된 기본 생성자 사양에서 기본 생성자에 대해 자세히 알아볼 수 있습니다.