생성자(Constructor)

생성자와 소멸자를 간단히 소개하자면, 생성자는 객체를 생성할 때 호출되는 메서드이며, 소멸자는 객체가 소멸될 때 호출되는 메서드라고 할 수 있습니다. 우선 생성자 부터 알아보고, 이 생성자가 어떻게 사용되는지 아래 예제를 살펴보며 생각해봅시다. 그러기 전에, 생성자의 선언 형식부터 잠깐 보고 들어가보도록 하겠습니다.

class 클래스명 {
	// 여기서 대괄호([])는 선택 사항이라는 의미다.
	// 따로 제한자를 적지 않으면 기본 접근 제한자인 internal이 사용된다.
    [접근 제한자] 클래스명(매개변수1, 매개변수2, ...) {
        // ...
    }
    // ...
}

생성자의 선언 형식을 살펴보면, 생성자의 이름은 클래스의 이름과 똑같고, 생성자는 메서드와 같이 매개변수를 가질 수 있다는 사실을 알 수 있습니다. 하지만 생성자는 메서드와는 달리 반환형이 없으며, 이는 즉 어떤 값을 반환할 수는 없다는 말입니다.

단일문 생성자(single-statement constructor)

생성자 내의 본문에 하나의 문장만 있을 때는 아래와 같이 C#에서 지원하는 표현식 본문 정의(expression body definition)를 사용해서 간결하게 바꿀 수도 있습니다. 이는 C# 7.0부터 사용할 수 있습니다.

private string name;

// 하나의 문장으로 이루어진 생성자
public Person(string name)
{
	this.name = name;
}

// 표현식 본문 정의를 사용한 간단한 버전의 생성자
public Person(string name) => this.name = name;

디폴트 생성자(Default Constructor)

그리고 한가지, 여기서 드러나지는 않았지만 생성자는 객체 생성 시 자동으로 호출되는 특별한 메서드이며, 우리가 따로 생성자를 직접 만들어주지 않아도 컴파일러에서 생성자를 알아서 만들어줍니다. 따라서 이 생성자를 디폴트 생성자(default constructor 혹은 기본 생성자)라고 부릅니다.

class Person {
	// public Person() { }
	...
}

디폴트 생성자는 위와 같이 매개변수가 없는 본문이 빈 생성자이며, 우리가 생성자를 하나라도 직접 정의한다면 디폴트 생성자는 만들어지지 않습니다. 예를 들어서 아래의 경우에는 매개변수를 받는 생성자가 이미 정의되어 있으므로 컴파일러는 디폴트 생성자를 만들지 않습니다.

class Person
	// ...
	public Person(string name, int age)
	{
		this.name = name;
		this.age = age;
	}
	// ...
}

예시 살펴보기

이제 한번 예시를 살펴보면서 생성자의 역할은 무엇인지 같이 살펴보도록 합시다. 아래에는 자동차를 논리적으로 표현한 Car 클래스가 있으며, 내부에는 모델명, 속도, 최대 속도를 담고 있습니다.

namespace CSharpTutorial
{
    class Program
    {
        static void Main(string[] args)
        {
	        // 정수 325와 문자열 "BMW"는 각각 Car의 생성자 매개변수인
	        // maxSpeed와 model로 넘어간다.
            Car car = new Car(325, "BMW");

            car.ShowCarInformation();
            car.IncreaseSpeed(50); // 현재 속도(speed): 50km/h
            car.IncreaseSpeed(40); // 현재 속도(speed): 90km/h
            car.IncreaseSpeed(210); // 현재 속도(speed): 300km/h
            // 최대 속도인 325km/h를 넘어설 수 없다. (300 + 30 = 330)
            car.IncreaseSpeed(30);
        }
    }

    class Car
    {
        private int maxSpeed;
        private int speed;
        private string model;

		// Car 객체가 만들어지면 자동으로 이 생성자가 호출된다.
        public Car(int maxSpeed, string model)
        {
            this.speed = 0;
            this.maxSpeed = maxSpeed;
            this.model = model;
        }

        public void ShowCarInformation()
        {
            Console.WriteLine(model + "의 현재 속도: " + speed + "km/h, 최대 속도: " + maxSpeed + "km/h");
        }

        public void IncreaseSpeed(int increment)
        {
            if (speed + increment > maxSpeed)
                Console.WriteLine("최대 속도 " + maxSpeed + "km/h를 넘길 수 없습니다.");
            else
            {
                speed += increment;
                Console.WriteLine(model + "의 현재 속도는 " + speed + "km/h 입니다.");
            }
        }

        public void DecreaseSpeed(int decrement)
        {
            if (speed - decrement < 0)
                Console.WriteLine("속도는 0 아래로 떨어질 수 없습니다.");
            else
            {
                speed -= decrement;
                Console.WriteLine(model + "의 현재 속도는 " + speed + "km/h 입니다.");
            }
        }
    }
}

위에서 생성자만 살펴보면 다음과 같습니다. 생성자는 다음과 같이 필드를 기본값으로 초기화하는데 완벽한 위치입니다. 그 외에도 private 생성자와 같이 접근 제한을 낮춰서 객체를 외부에서 생성하지 못하게 하거나, 생성자가 아닌 다른 방법으로 객체를 생성할 수 있도록 강제할 수도 있습니다.

public Car(int maxSpeed, string model)
{
	this.speed = 0;
	this.maxSpeed = maxSpeed;
	this.model = model;
}

필드 초기화와 생성자를 통한 초기화

아래에서 초기화 순서는 어떻게 될까요? 생성자를 통한 초기화가 먼저 일어날까, 아니면 필드 초기화가 먼저 일어날까요?

class Foo {
	private int data = 10; // (1)
	private double range = 5.5; // (2)
	private string name;

	public Foo(string name)
	{ 
		this.name = name; // (3)
	}
}

위는 사실 다음과 같습니다. 인스턴스 필드를 초기화하는 부분은 아래와 같이 생성자의 첫 줄에 삽입된다고 생각해도 됩니다. 따라서 필드 초기화가 먼저 일어나고 생성자 초기화가 일어나며, 필드 초기화의 순서는 소스 코드에서 필드가 선언된 순서를 따라갑니다.

class Foo {
	public Foo(string name)
	{
		this.data = 10; // 새로 삽입된 줄
		this.range = 5.5; // 새로 삽입된 줄
		this.name = name;
	}
}

생성자 오버로딩

위에서 생성자는 특별한 메서드라고 했습니다. 메서드가 오버로딩이 가능한 것처럼, 생성자도 오버로딩이 가능합니다. 아래 예를 같이 살펴봅시다. 메서드 오버로딩 편에서 살펴봤던 규칙이 생성자 오버로딩에도 그대로 적용되는 것을 볼 수 있습니다.

namespace CSharpTutorial
{
    class Program
    {
        static void Main(string[] args)
        {
	        // 넘어가는 매개변수의 타입이나 수에 따라서 실행되는 생성자가 다르다.
            MyClass objA = new MyClass();
            MyClass objB = new MyClass(10);
            MyClass objC = new MyClass(25.5);
        }
    }

    class MyClass
    {
        public MyClass()
        {
            Console.WriteLine("매개변수가 없는 디폴트 생성자가 호출됨");
        }

        public MyClass(int num)
        {
            Console.WriteLine("정수형 매개변수 num을 받는 생성자가 호출됨");
        }

        public MyClass(double num)
        {
            Console.WriteLine("실수형 매개변수 num을 받는 생성자가 호출됨");
        }
    }
}

객체 이니셜라이저

객체 이니셜라이저를 통해서 생성자를 통하지 않고도 쉽고 간편하게 필드의 값을 초기화할 수 있습니다. 사용법은 객체를 생성할 때 다음과 같이 중괄호({...}) 안에 필드명과 초기화할 값을 나열하면 됩니다. 여러 필드를 초기화할 경우에는 콤마(,)로 구분합니다. 나중에 살펴볼 프로퍼티(property)에서도 객체 이니셜라이저를 사용할 수 있습니다.

Foo foo = new Foo() { 필드1=값, 필드2=값, ... };

// 디폴트 생성자의 경우는 아래와 같이 소괄호를 생략할 수도 있다.
Foo foo = new Foo { 필드1=값, 필드2=값, ... };

여기서 물론 필드에는 접근할 수 있어야 사용할 수 있습니다. 예를 들면 해당 필드의 접근 제한이 private로 되어 있으면 외부에서 접근할 수 없으므로 객체 이니셜라이저를 사용할 수가 없습니다. 예시를 간단하게 살펴봅시다.

namespace CSharpTutorial
{
    class Program
    {
        static void Main(string[] args)
        {
			// 객체 이니셜라이저 사용
			Foo foo1 = new Foo() { Name="foo", Value=3 };
			Foo foo2 = new Foo("foo") { Value=3 };
			
			// 사실상 위는 아래와 동일
			Foo foo1 = new Foo();
			foo1.Name = "foo";
			foo1.Value = 3;
			
			Foo foo2 = new Foo("foo");
			foo2.Value = 3;
        }
    }

    class Foo
    {
        public string Name;
        public int Value;

        public Foo() { }

        public Foo(string name)
        {
            Name = name;
        }
    }
}

물론 이를 아래와 같이 중첩하여 사용할 수도 있습니다.

namespace CSharpTutorial
{
    class Program
    {
        static void Main(string[] args)
        {
            Person person = new Person
            {
                Name = "홍길동",
                Age = 20,
                Address = new Address
                {
                    Street = "서울시 서초구 서초동",
                    City = "서울",
                    State = "서울특별시"
                }
            };

            Console.WriteLine(person.Name); // 홍길동
            Console.WriteLine(person.Age); // 20
            Console.WriteLine(person.Address.City); // 서울
            Console.WriteLine(person.Address.State); // 서울특별시
        }
    }

    class Person
    {
        public string Name;
        public int Age;
        public Address Address;
        // ...
    }

    class Address
    {
        public string Street;
        public string City;
        public string State;
        // ...
    }
}

한 걸음 더 나아가기

파이널라이저(Finalizer)

파이널라이저(finalizer, 또는 종결자)는 생성자와는 다르게 가비지 컬렉터(garbage collector)가 객체가 소멸하는 시점을 판단하여 호출되는 메서드입니다. 여기서 가비지 컬렉터란, C#에서 효율적인 메모리 관리를 위해 가비지 컬렉터란 녀석이 자동으로 더는 사용되지 않는 객체를 수거해가는 역할을 합니다(이는 나중에 더 자세히 다룰 예정임). 

class 클래스명 {
    ~클래스명() {
        // ...
    }
    
    ..
}

위와 같이 클래스의 이름 앞에 ~ 기호를 붙이면 바로 파이널라이저가 됩니다. 파이널라이저는 생성자와는 달리 클래스 내에 단 하나만 정의할 수 있으며 상속받을 수도, 파이널라이저를 오버로딩할 수도 없습니다(상속에 관해선 다음에 설명함). 심지어 사용자가 직접 호출할 수도 없으며, 접근 제한자를 가질 수도 없고 매개변수를 가질 수도 없습니다. 따라서 파이널라이저는 반드시 위의 형태로 작성해야 합니다. 아래는 파이널라이저에 관한 예시입니다.

namespace CSharpTutorial
{
    class Program
    {
        static void Main(string[] args)
        {
            MyClass objA = new MyClass("A");
            MyClass objB = new MyClass("B");
            MyClass objC = new MyClass("C");
        }
    }

    class MyClass
    {
        private string name;

        public MyClass(string name)
        {
            Console.WriteLine(name + " 객체 생성!");
            this.name = name;
        }

        ~MyClass()
        {
            Console.WriteLine(name + " 객체 소멸!");
        }
    }
}

위의 예제를 실행해보면 뭔가 이상하다는 생각이 들 수도 있습니다. 분명 소멸자는 객체가 소멸될 때 호출된다고 했는데 "객체 소멸!"이라는 문장은 아무것도 출력되지 않습니다. 또한 실행되었다고 하더라도 소멸의 순서가 뒤죽박죽인 것을 볼 수 있습니다. 이는 가비지 컬렉터가 언제 동작할지, 어떤 순서로 객체를 소멸시킬지 알 수 없기 때문입니다. 파이널라이저는 여기서는 잠깐만 살펴보고 뒷부분에서 가비지 컬렉터를 다룰 때 함께 자세하게 살펴볼 예정입니다. 지금은 그냥 이런 것도 있구나 하고 받아들여 주세요.

프로그램 종료 시 호출되는 파이널라이저

아래는 마이크로소프트의 C# 문서에서 확인할 수 있는 내용입니다.

프로그램을 종료할 때 파이널라이저가 실행되는지에 대한 여부는 .NET의 구현에 따라서 제각각입니다. 프로그램이 종료될 때 .NET Framework는 아직 수거되지 않은 객체에 대해 파이널라이저를 호출하기 위해서 최선을 다합니다(GC.SuppressFinalize() 같은 라이브러리 메서드를 호출하여 "이 객체는 이미 정리가 끝났다"고 표시한 객체에 대해서는 호출되지 않음). .NET 5(.NET Core 포함) 이상 버전에서는 프로그램이 종료될 때 더 이상 파이널라이저를 호출하지 않습니다. 자세한 내용은 깃허브 이슈를 확인해주세요.