CLR은 두 가지 종류의 타입, 참조 형식과 값 형식이 있다.
C# 의 new 연산자에 의해서 개체의 메모리 주소(메모리 주소는 개체를 참조함)를 반환받는다. 개발자가 참조 형식을 이용해서 작업할 때는 성능에 대한 고려를 미리 해야 한다.
- 메모리는 힙 영역으로부터 할당받아야 한다.
- 힙에서 할당받은 각각의 개체에 포함된 멤버 중 몇몇은 초기화가 필요함으로 오버헤드가 발생할 수 있다.
- 개체 내의 다른 바이트들은 항상 0으로 초기화된다.
- 힙 영역으로부터 개체를 할당하는 것은 GC를 발생시킬 수 있다.
만약, 모든 타입이 참조 형식이라면 애플리케이션의 성능은 크게 저하될 것이다. 개발자가 매번 Int32 를 사용할 때마다 메모리 할당이 발생한다면 성능이 매우 느려질 것이다. 값 형식 인스턴스는 보통 스레드의 스택에서 할당 받는다(참조 형식 개체에 멤버로서 포함될 수 있다). 값 형식 인스턴스를 표현하는 변수는 인스턴스 참조 포인터를 포함하지 않는다.
값 형식의 경우, 변수는 자기 자신이 인스턴스와 필드를 포함하고 있다. 따라서 포인터가 따로 참조될 필요가 없다. 이는 힙 영역의 부담을 덜어주니 GC의 부담도 덜어준다.
클래스라고 불리는 타입들은 참조 형식이다.
- System.Exception
- System.IO.FileStream
- System.Random
문서에 구조체와 열거형으로 기술되어 있으면 값 형식으로 구분된다.
- System.Int32
- System.Boolean
- System.Decimal
모든 구조체들은 추상 타입인 System.ValueType을 직접 상속받는다. System.ValueType은 System.Object 타입으로부터 직접 파생되었다. 모든 값 형식은 System.ValueType으로부터 반드시 파생되도록 정의되어 있다.
모든 열거형들은 System.ValueType으로부터 파생된 System.Enum이라는 추상 타입으로부터 파생된다. CLR과 모든 개발 언어들은 열거형을 특별하게 다룬다(열거형 타입과 비트 플래그).
값 형식을 정의할 때 상위 타입(base class)을 선택할 수 없다(단, 인터페이스를 구현하는 것은 가능). 모든 값 형식은 봉인됨(sealed)으로써 다른 참조 형식이나 값 형식의 상위 타입으로 사용할 수도 없다(Boolean, Char, Int32 등을 상위 타입으로 사용하여 새로운 타입을 정의하는 것은 불가능).

메모리가 관리되지 않는 언어(Unmanaged, C, C++)의 개발자들은 어색할 수 있다. C나 C++에서는 타입을 정의하면 그 타입을 사용할 코드에 의해 해당 타입의 인스턴스가 스레드 스택에서 할당되어야 할지, 힙에 할당할지 결정하지만, 메모리가 관리되는 코드(Managed, C#, Java)에서는 개발자가 타입을 정의하면서 타입의 인스턴스가 결정한다.



개발자가 타입을 디자인할 때 참조 형식보다 값 형식으로 정의가 가능한지 먼저 고려해야 한다. 몇몇 상황에서는 보다 나은 성능을 내기 때문이다. 다음의 내용에 해당한다면 값 형식이 바람직하다.
- 타입이 기본 형식처럼 행동한다. 특히 인스턴스의 필드를 수정하는 멤버들을 가지지 않는 간단한 타입을 말한다. 타입이 필드를 수정할 수 있는 멤버를 포함하지 않는다면 그 타입을 변하지 않는다(Immutable)고 한다.
- 타입이 다른 타입들로부터 상속될 필요(base class가 될 일이 없는 경우)가 없다.
- 타입의 인스턴스 크기가 대략 16바이트 정도이다.
- 타입의 인스턴스가 18바이트보다 더 크고 메서드의 인자나 반환형으로 사용되지 않는다.
타입의 인스턴스 크기도 고려해야 한다. 기본적으로 인자들은 by value 형태로 전달되기 때문에 인자가 값 형식이라면 모든 멤버가 복사되어야 하므로 성능에 부담이 될 수 있다. 역시 값을 반환할 때에도 호출자의 메모리 쪽으로 반환 인스턴스의 모든 멤버를 복사해야 하므로 성능에 부담이 될 수 있다.
관리되는 힙 영역에서 할당되지 않는 것은 값 형식의 가장 큰 장점이지만, 몇 가지 한계를 가진다. 다음은 참조 형식과 차이점이다.
- 값 형식의 개체는 두 가지의 표현 방식이 있다. 언박스 형태와 박스 형태가 있다. 참조 형식은 언제나 박스 형태임.
- 기본 구현의 성능이 이슈가 될 수도 있다. 값 형식은 System.ValueType을 상속받는다. 이 타입은 System.Object에 의해 정의된 동일한 메서드를 제공하기 때문이다.
- System.ValueType은 Equals 메서드를 재정의하여 두 개체의 모든 필드 값이 일치하면 true를 반환하도록 되어 있다.
- System.ValueType은 개체 인스턴스 필드를 바탕으로 해서 값을 생성하도록 GetHashCode 메서드도 재정의한다.
- 따라서, Equals 메서드와 GetHashCode 메서드를 명시적으로 정의하는 것이 좋다.
- 값 형식은 sealed이기 때문에 추상 및 가상 메서드를 정의할 수 없다.
- 참조 형식 변수들은 개체의 힙 영역 메모리 주소를 가지므로, null 상태가 되어 아무것도 가리키지 않을 수 있다(null인데 참조할 경우 NullReferenceException 예외 발생). 이와 대조적으로, 값 형식의 변수는 언제나 해당 타입에 대한 기본 값을 가지고 있다.
- CLR은 값 형식에 null 개념이 추가된 특별한 기능을 제공한다(Nullable).
- 값 형식 변수를 또 다른 값 형식 변수로 참조했을 때 모든 필드가 복사된 복사본이 만들어진다. 반면에 참조 형식은 메모리 주소만 복사된다.
언박싱된 값 형식은 힙 영역에 할당되지 않는다. 때문에 해당 저장 공간을 사용하는 메서드가 유효하지 않게 되면 즉시 삭제된다. 값 형식 인스턴스의 메모리가 해지될 때 이를 통지 받지(Finalize 메서드를 경유하여) 못한다는 것을 의미한다.
- 사실, 값 형식에서 Finalize 메서드는 박싱된 상태일 때만 호출한다. 이 때문에 많은 컴파일러들은 개발자가 값 형식에 Finalize 메서드를 정의하는 것을 금지한다. 비록 허용할지라도 박스 상태의 값 형식 인스턴스의 경우 GC에 의해 수집될 때 CLR은 호출하지 않는다.
성능 향상을 위해 CLR 이 타입의 필드 배치(Layout)를 조정할 수 있는 기능이 있다. 메모리 내에서 필드들을 재배치하여 개체의 참조들이 함께 그룹화되거나 데이터 필드들이 정렬하도록 할 수 있다. 또 개발자가 타입을 정의할 때 개발자가 정의한 순서로 타입의 필드를 유지할 것인지 아니면 CLR이 원하는 순서로 재정렬하도록 설정할 수 있다.
- 정의하는 클래스나 구조체에 System.Runtime.InteropServices.StructLayoutAttribute 속성을 적용하면 된다.
- 이 속성의 생성자를 통해 인자에 LayoutKind.Auto 를 설정하면 CLR이 필드를 재정렬한다.
- LayoutKind.Sequential 값을 전달하면 CLR은 개발자가 정의한 순서를 유지할 것이다.
- LayoutKind.Explicit 값을 전달하면 오프셋에 의해 메모리상의 필드 순서가 명시적으로 정해진다.
- StructLayoutAttribute 값을 특별히 전달하지 않으면 컴파일러는 스스로 최선의 정렬 방법을 택한다.
- Microsoft의 C# 컴파일러는 기본적으로 참조 형식에는 LayoutKind.Auto 값을 선택하고, 값 형식에는 LayoutKind.Sequential을 선택한다.

- 참조 형식과 값 형식을 중첩하도록 정의하는 것은 허용되지 않는다.
- 같은 오프셋 위치에 다중의 참조 형식을 중첩하도록 정의하는 것은 가능하다.
- 다중의 값 형식을 중첩되게 정의하는 것은 가능하나 중첩된 모든 바이트가 public 필드를 통해 액세스가 가능해야 한다. 만일, 하나의 값 형식의 필드는 public 이고, 이와 중첩된 또 다른 값 형식의 필드가 private이면 타입은 검증되지 않는다.


출처 : CLR via C# <2nd Edition> : [Chapter 5. 참조 형식과 값 형식] : 205p ~ 213p
'C#' 카테고리의 다른 글
| CLR : System.Object (0) | 2021.03.20 |
|---|