언어/C#

C# 의 타입 - 3. 값 타입과 참조타입 / 박싱, 언박싱

tsyang 2020. 11. 14. 18:12

참조 타입과 값 타입


CLR은 참조 타입(Reference Type)와 값 타입(Value Type)이라는 두 종류의 타입 체계를 지원한다. 

 

참조 타입은 항상 관리되는 힙에 할당된다. 따라서 참조 타입을 사용할 때 마다 메모리의 할당이 발생하는데, 모든 타입이 참조 타입이라면 엄청난 성능 저하가 있을 것이다. 따라서 성능 향상을 목적으로, 단순하고 자주 사용되는 타입들을 위하여 경량화된 값 타입이 존재한다. 값 타입의 인스턴스는 그 자체가 하나의 필드이며 필드를 얻어내기 위해 역추적을 할 필요가 없고 GC의 관리 대상또한 아니다. 

 

C#에서 모든 클래스는 참조 타입이며 모든 구조체, 열거 타입은 값 타입니다. (System.Int32등도 구조체이다.)

 

모든 구조체 타입은 System.ValueType 추상 타입을 상속한다. 그리고 System.ValueType은 System.Object타입을 상속받는다. 모든 열거형은 System.Enum을 상속받으며, System.Enum은 다시 System.ValueType을 상속받는다. 

 

값 타입은 힙이 아닌 스레드 스택에 할당된다. 

 

 

새로운 타입을 만들 때에는 값 타입 또는 참조 타입 중 어떤 것으로 택하는 것이 좋을지 고민을 해봐야 한다. 다음을 모두 만족한다면 값 타입으로 선언하는게 옳다.

 

  • 새로 만드는 타입이 기본 타입처럼 쓰인다. 
  • 다른 타입으로부터 상속 받을 필요가 없다.
  • 다른 타입의 기본 타입으로 쓰이게 할 계획이 없다.
  • 인스턴스의 전체 크기가 약 16바이트 이내로 매우 작다.
  • 인스턴스가 16바이트보다 크지만 메서드의 매개변수나 반환 타입으로 쓰이는 일이 없음을 확실히 할 수 있다.

기본적으로 값 타입의 경우 힙에 할당되는 인스턴스와 다르게 할당시 모든 값의 복사가 일어나므로 매개변수 등으로 쓰이면 느리다. 

 

 

값 타입과 참조 타입이 다른 점은 다음과 같다. 

 

  • 값 타입은 System.ValueType 타입으로부터 항상 상속된다. System.ValueType은 System.Object타입과 동일한 기본 메서드들을 제공하지만, Equals메서드를 재정의 하하여 두 객체의 필드 값이 일치할 때 true를 반환한다. (Object는 참조가 같을 때 true) GetHashCode도 필드의 값을 이용하도록 수정된다. 그러나 기본 버전은 성능이 빠르지 않으므로 명시적으로 재정의 해주는 것이 좋다.
  • 값 타입은 가상 함수를 만들 수 없으며 모든 메서드는 암묵적으로 재정의가 불가하다.
  • 참조 타입의 변수는 객체의 메모리 주소가 힙을 가리킨다. 값 타입의 경우 모든 필드 멤버들을 0으로 초기화된다. 
  • 값 타입의 변수를 다른 값 타입의 변수로 대입하면 필드 단위로 하나씩 복제가 이뤄진다. 그러나 참조 타입의 변수끼리 대입이 발생하면 단순히 메모리 주소만 복제된다.
  • 값 타입이 박싱되지 않은 상태로 할당되면 메서드와 함께 선언되고 할당되기 때문에, 메서드의 실행이 끝나자마자 메모리가 할당 해지된다. 반면 참조 타입은 GC를 만달 때까지 기다린다.

 

 

값 타입의 박싱과 언박싱


 

박싱

값 타입은 참조 타입보다 가볍다. 그 이유는 관리되는 힙에 객체로 할당되지 않으며, GC가 관리하지 않고 포인터로 가리켜지지도 않는다. 그러나 값 타입에 대한 참조를 얻어야만 할 때가 있는데 가장 흔한 예는 Array같은 컬렉션이다.

 

예를 들어 다음과 같은 코드를 작성했다고 치자.

 

struct Vector2Int
{
    public int x, y;
}

public sealed class Program
{
    public static void Main()
    {
        ArrayList arr = new ArrayList();
        Vector2Int v; // 힙 밖에 생성된다.
        for(int i=0;i<10; ++i)
        {
            v.x = v.y = i; // 값 타입을 초기화해야함 (new를 사용하지 않았으므로)
            arr.Add(v); // 뭐가 들어갈까?
        }
    }
}

 

arr.Add(v)가 호출되면 arr에 무엇이 들어갈까? Vector2Int 구조체일까 아니면 구조체의 주소일까?

 

ArrayList의 Add는 object를 매개변수로 받는다. 즉, Add는 객체의 참조나 포인터 주소를 받아오며 이것은 관리되는 힙상의 객체에 대한 주소이다. 

 

하지만 Vector2Int 는 구조체이기 때문에 값 타입이고 따라서 arr에 더해지기 위해서는 참조 타입으로 변환되어야 한다. 

이 과정이 박싱이며, 다음과 같은 일들이 일어난다.

 

  • 관리되는 힙에 메모리가 할당된다. 이때 메모리의 크기는 값 타입 내에 들어있는필드들의 메모리 크기에 더하여 다른 힙의 객체들처럼 타입 객체 포인터와 동기화 블록 인덱스를 포함한다. 
  • 값 타임의 필드들이 새로 할당된 힙 메모리에 복사된다.
  • 객체의 메모리 주소가 반환된다. 주소는 이제 객체에 대한 참조이며 값 타입은 참조 타입으로 취급된다.

이제 값 타입은 다른 참조타입들 처럼 GC를 만날 때 까지 메모리에 남게 된다. 

 

 

*근데 이제 제너릭 컬렉션들이 나왔으니까 이걸 쓰면 됨. 얘네는 박싱도 언박싱도 일어나지 않아서 성능상에 큰 이점이 있다.

 

언박싱

 

위에서 만든 ArrayList에 담겨있는 요소를 가져온다고 해보자

 

Point p = (Point) a[0];

 

a[0] 은 참조타입이지만 이것을 값 타입인 p에 넣으려고 하고 있다. 이것은 a[0]안의 필드들을 p의 필드에 복사하는 것인데 CLR은 이 과정을 두 단계에 걸쳐서 수행한다.

 

  1. 박싱된 Point 타입에 들어있는 Point 필드들의 주소를 가져온다. (언박싱)
  2. 이 필드들의 값을 힙에서 복사하여 스택의 값 타입 인스턴스 쪽으로 복사해 넣는다

언박싱은 박싱보다는 훨씬 적은 연산을 수행한다. 박싱은 메모리 복사를 수반하는데 비해 언박싱은 그렇지 않기 때문이다.  그러나 둘 다 성능 저하를 일으키게 되는건 마찬가지임.

 

또한 박싱된 객체를 다시 언박싱하는 캐스트를 수행할 때, 원래의 타입과 똑같은 타입으로 지정해주어야 한다. Int32 객체를 박싱한 후, Int16으로 캐스팅하면 InvalidCastException이 발생한다.

 

 

 

+)

 

박싱되지 않은 값 타입이 참조 타입보다 가벼운 이유 

1. 관리되는 힙 메모리가 아닌 스택 메모리 공간에 할당

2. 힙 메모리상에 할당되는 참조 타입 객체들은 기본적을 ㅗ타입 객체 포인터와 동기화 블록 인덱스라는 두개의 추가 필드가 자동으로 할당됨.

 

박싱되지 않은 값 타입들은 동기화 블록 인덱스가 없기 때문에 다중 스레드 환경에서 인스턴스에 대한 접근 제어를 위해 쓰이는 Threading.Mnoitor나 c#의 lock구문을 쓸 수 없다.  (동기화 블록 인덱스가 없어도 스레드끼리는 스택 따로쓰니까...)

 

 

 

 

'언어 > C#' 카테고리의 다른 글

C# 메서드 - 1. 생성자  (0) 2020.11.22
C# 의 타입 - 4. 객체의 식별 (Equals, GetHashCode)  (1) 2020.11.14
C# 의 타입 - 2. 기본 타입 (Primitve Type)  (0) 2020.10.31
C# 의 타입 - 1. 타입의 기초  (0) 2020.10.31
Attribute  (0) 2020.10.17