언어/C#

C# 메서드 - 1. 생성자

tsyang 2020. 11. 22. 14:03

인스턴스 생성자 - 클래스 (참조타입)


생성자는 항상 .ctor이라는 이름으로 불리고 이 이름으로 메서드 정의 메타데이터 테이블에 등록된다. 

 

참조 타입으로 객체의 인스턴스를 생성하면, 데이터 필드들을 저장하기 위한 메모리가 할당되는데 그 과정에서

  1. 먼저 객체의 오버헤드 필드(타입 객체 포인터와 동기화 블록 인덱스)가 초기화되고,
  2. 그 다음에 타입의 인스턴스 생성자가 호출되어 객체의 초기 상태를 설정한다.

참조타입의 객체를 생성하면, 할당된 메모리는 우선 모두 0으로 초기화되며, 이후 타입의 인스턴스 생성자가 호출된다. 

 

다른 메서드들과 다르게 인스턴스 생성자는 상속되지 않는다. (따라서 virtual,new,override,sealed,abstract 같은 키워드를 사용할 수 없다.) 

 

만약 클래스를 정의할 때 어떠한 생성자도 만들지 않았다면 C# 컴파일러는 다음과 같이 매개변수 없는 기본 생성자를 만들어준다. 

 

public class MyClass
{
	public MyClass() : base() {}
}

 

클래스를 abstract로 선언하면 컴파일러는 기본 생성자를 protected로 선언하며 그렇지 않은 경우에는 public으로 선언된 것으로 간주한다. 

 

클래스를 static으로 선언하면 컴파일러는 기본 생성자를 정의에 포함시키지 않는다.

 

또한 C# 컴파일러는 상속한 클래스가 명시적으로 기본 클래스의 생성자를 호출하지 않으면, 기본 클래스 생성자를 자동으로 호출한다. 종단에는 System.Object의 기본 생성자가 호출된다. (Object 타입에는 필드가 없으므로 Object의 기본 생성자는 아무것도 하지 않는 빈 함수이다.)

 

생성자 내부에서는 가상 메서드를 호출해서는 안된다. 가상 메서드가 재정의된 경우 하위 타입에서 정의한 메서드를 호출하는데 이 과정에서 초기화가 덜 끝난 객체를 만나게 되어 예측할 수 없는 오류를 일으킬 수 있다.


인스턴스 생성자가 호출되지 않고도 인스턴스가 생성되는 경우가 있다.

 

그 중 하나가 Object 타입의 MemberwiseClone 메서드이다. 이 메서드를 호출하면 메모리를 할당하고, 오버헤드 필드를 초기화 한 다음, 원본 객체의 바이트들을 새 객체로 직접 복사한다. 

또한 Serialization을 통해 만든 데이터를 deserializeation하는 경우에도 생성자 호출이 별도로 이뤄지지 않는다.

 


 

C#에서는 다음과 같이 필드를 초기화하는 문법을 제공한다.

public class MyClass
{
	private int val = 5;
}

이렇게 되면 MyClass 객체가 생성될 때 val 이 5로 초기화된다. 이 과정은 어떻게 일어날까?

 

해당 클래스의 .ctor메서드에 대한 IL 코드를 보면 생성자가 val의 값을 5로 초기화 한 후, 기본 클래스(System.Object)의 생성자를 호출함을 알 수 있다. 

 

public class MyClass
{
    private int val = 5;
    public MyClass() { val = 10; }
}

즉, 위와같은 코드에서는

  1. val은 먼저 5로 초기화 된 뒤,
  2. 기본 클래스(Object)의 생성자가 실행된 다음
  3. val의 값을 10으로 덮어씀

의 순서로 초기화가 이뤄진다.

 

 

 

인스턴스 생성자 - 구조체 (값 타입)


값 타입의 생성자는 참조 타입의 생성자와는 다르다. CLR에서는 값 타입의 인스턴스를 언제든 생성할 수 있도록 허용하므로, 이를 막을 방법이 없다. 이런 이유로 값 타입은 내부에 생성자를 정의할 필요가 없으며, C#컴파일러는 값 타입에 대해선 매개변수가 없는 기본 생성자 코드를 생성하지 않는다.

 

다음과 같은 코드를 보자.

 

struct Point
{
    public int x, y;
}

sealed class Rectangle
{
    public Point bottomLeft, topRight;
}

 

Rectangle(클래스) 인스턴스를 만들려면 new 연산자를 반드시 사용해야 하고, 생성자를 지정해야 한다. 위의 경우에는 C# 컴파일러가 자동으로 생성한 기본 생성자가 호출된다. Rectangle 인스턴스는 메모리를 할당할 때 Point 값 타입의 인스턴스가 필요로 하는 메모리도 고려를 해야한다. 성능상의 이유로 CLR은 참조 타입 내에 선언된 값 타입 필드들의 생성자를 호출하지는 않지만 값 타입의 필드들을 0이나 null로 초기화한다.

 


 

CLR은 값 타입에 대해서 생성자를 정의할 수 있도록 허용한다. 단, 정의한 생성자를 실행하는 유일한 방법은 명시적인 호출뿐이다. 

 

struct Point
{
    public int x, y;

    public Point()
    {
        x = y = 5;
    }
}

sealed class Rectangle
{
    public Point bottomLeft, topRight;
}

위의 코드에서 Rectangle 내의 Point의 x,y값은 0일까 5일까? 

 

CLR은 값 타입의 기본생성자를 만들어주지 않는다. 그렇기에 기본 생성자를 호출하지도 않는다. (위에서도 정의한 생성자를 실행하는 방법은 명시적 호출뿐이라고 썻다.)

 

그렇기에 Point의 필드 값들은 0으로 남게 된다. 그러나 이것은 프로그래머들에게 오해의 소지를 줄 수 있기 때문에 (다른 언어에서는 struct도 기본 생성자를 만들고 호출하기도 하므로) C#에선 값 타입에 대해서는 매개변수 없는 기본 생성자를 허용하지 않는다. (근데 CLR에서는 허용함..)

 

 

 

struct Point
{
    public int x, y;

    public Point(int x)
    {
        this.x = x;
    }
}

sealed class Rectangle
{
    public Point bottomLeft, topRight;
}

C#은 위와같은 코드도 허용하지 않으며 다음의 오류를 발생시킨다.

 

 

Point의 생성자에서 x는 초기화 하지만 y는 초기화하지 않기 떄문이다. 이처럼 값 타입의 생성자는 모든 필드를 초기화 해주어야 한다. 

 

만약 필드가 엄청 많은데 일부분만 원하는 값으로 초기화 하고 나머지는 0으로 초기화하고 싶다면 어떻게 할까?

 

struct Point
{
    public int x, y;

    public Point(int x)
    {
        this = new Point(); //new 연산자를 통해 모든 필드를 0또는 null로 초기화한다.
        this.x = x;
    }
}

sealed class Rectangle
{
    public Point bottomLeft, topRight;
}

this는 값 타입 인스턴스 그 자체를 나타내며, 값 타입의 인스턴스를 아예 새로 만들어 여기 대입하는게 가능하다. 이렇게 모든 필드를 0으로 초기화 한 뒤 원하는 값만 따로 덮어쓸 수 있다.

 

그러나 참조 타입의 생성자에서는 this가 읽기 전용이므로 위와 같은 코드는 쓸 수 없다

 

 

 

 

타입 생성자 (정적 생성자)


타입 생성자는 정적 생성자, 클래스 생성자 혹은 타입 초기자라고도 불린다. 

타입 생성자는 인터페이스에 대해서도 적용이 가능하며 (CLR은 되는데 C#에서는 안 된다.)

 

인스턴스 생성자는 인스턴스의 초기 상태를 설정하기 위해 사용하고, 타입 생성자는 타입의 초기 상태를 설정하기 위해서 사용한다.

 

타입은 내부적으로 사용하는 기본 타입 생성자가 없으며, 타입 생성자는 타입당 하나만 정의할 수 있으며 매개변수를 가질 수 없다.

 

sealed class MyClass
{
    static MyClass() { } // MyClass 타입을 처음 사용하면 이 생성자가 수행된다.
}

위처럼 타입 생성자를 정의하고 나면, 해당 타입을 처음 사용할 때 타입 생성자가 호출된다.

 

단, 유의할점은 값 타입에서는 정적 타입 생성자를 정의할 수는 있지만 CLR이 절대 이것을 호출하지 않기 때문에 의미 없는 코드가 된다.

타입 생성자가 호출되는 과정은 다음과 같다. 

  1. JIT컴파일러가 메서드를 컴파일 할 때 메서드 내의 코드가 어떤 타입들을 참조하는지 확인한다
  2. 만약 타입 생성자를 정의하고 있는 타입을 참조하면 JIT 컴파일러는 이 타입의 타입 생성자가 앱도메인에서 실행된 적 있는지 검사한다.
  3. 만약 한 번도 실행된 적 없다면 JIT 컴파일러는 타입 생성자 호출 코드를 네이티브 코드에 추가한다. 
  4. 만약 실행된 적이 있다면 타입 생성자 호출 코드를 추가하지 않는다.
  5. JIT컴파일이 완료ㅕ되고 스레드가 메서드를 실행하면서 타입 생성자를 호출한다.
  6. 이 때, 여러 스레드가 타입 메서드를 동시에 호출할 수 있기 때문에 CLR은 타입 생성자를 호출한 스레드가 상호 배타적인 락을 획득하도록 요구한다. 
  7. 이제 다른 스레드가 타입 메서드를 호출하기 전에 실행된 적이 있는지 확인한다. 따라서 타입 생성자가 재차 호출되는 일은 없다.
참고 : CLR은 타입 생성자가 앱도메인당 한 번씩만 실행하도록 Thread-Safe를 보증하기 때문에 싱글톤(Singleton)객체를 최적화 하기에 좋은 위치이다.
 주의할 점은 여러 타입들의 타입 생성자가 항상 어떤 지정된 순서대로 호출되어야 하는 코드를 짜면 안된다. CLR은 타입 생성자를 호출만 할 뿐이지 순서를 보장하지는 않는다. (멀티 쓰레딩 때문에 타입 생성자가 끝나는 순서가 다를 수 있는듯)

 


정적 필드들도 인스턴스 필드와 마찬가지로 초기화를 위한 간단한 문법을 제공한다.

 

sealed class MyClass
{
    static int val = 5;
}

그리고 이것은 다음과 같은 의미의 코드이다.

 

sealed class MyClass
{
    static int val;
    static MyClass() { val = 5; }
}

 

그리고 인스턴스 필드와 마찬가지로

 

sealed class MyClass
{
    static int val = 5;
    static MyClass() { val = 10; }
}

위와 같은 코드에서 val은 먼저 5로 초기화 된 뒤에 10으로 초기화된다.