C#에서 힙 할당을 줄이기 위한 언어 기능

반응형

struct(구조체) 사용

C#의 클래스는 참조형이므로, 클래스의 인스턴스를 너무 생성하면 GC에 부담이 걸린다. 반면에 C#에는 사용자 지정 값 타입을 정의하는 struct 기능이 있다.

이것은 C++의 class/struct와 거의 같고, 스택이나 클래스/배열 안에 직접 인스턴스를 확보할 수가 있다.

 

System.Numerics 네임스페이스에는 이를 이용한 복소수형 Complex 나 3D 벡터형 Vector3 등이 미리 정의되어 있다. 덧붙여 int 타입이나 float 타입 등도 (명목상은) struct의 일종으로 되어 있고, System 이름 공간에 있어서 각각 struct Int32, struct Single 로서 정의되고 있다.

 

 

 

함수에 ref,out으로 전달

C++에서의 포인터나 참조를 사용하는 용도 중의 하나는, 함수로부터 복수의 값을 돌려주는 것이다. 세상에는 힙 할당 없이 함수로부터 복수의 값을 돌려줄 수 없는 언어가 많이 있지만, C# 이라면 할 수 있다.

 

파라미터가 「입출력 겸용」이라면 ref, 「출력 전용」이라면 out을 사용한다. 물론 함수로부터 복수의 값을 돌려주기 위해서는, 대신에 구조체나 뒤에 이야기할 튜플을 사용 할 수도 있다.

또한 큰 구조체에 대해 반환 값으로 반환할 때 복사 오버헤드를 줄이는 데 사용할 수 있다.

 

 

 

값 유형 튜플 사용(C# 7.0 이상)

구조체를 정의할 정도는 아니지만 몇 가지 값 쌍을 사용하고 싶다. 이런 경우에 대응하기 위해 C# 7.0에서는 튜플 기능이 도입 되었다.

예를 들어, (double, int)라는 형식은 struct System.ValueTuple<double, int>의 별칭으로 double 과 int를 값 형식으로 유지할 수 있다. 덧붙여 C#의 튜플은 (double d, int i)같이 요소마다 이름을 붙일 수도 있고, 이것은 비교적 드문 언어 기능이라고 할 수 있다.

 

 

함수에 in으로 전달 (C# 7.2 이상)

C++에서의 포인터나 참조의 용도 중의 하나로, 예를 들어 입력 전용으로, 큰 클래스나 구조체를 복사하지 않고 건네준다 라는 것이 있다.

C# 7.2부터는 큰 구조체를 in으로 함수에 참조로 전달할 수 있다. 이것은 C++ const 참조와 유사하다. ref 와 out과는 달리, in으로 전달된 구조체의 내용은 변경할 수 없으므로 호출자에서 in 키워드를 붙일 필요는 없다.

 

 

제네릭스 사용

값 타입의 인스턴스를 object 타입 또는 interface 타입으로 변환하면 박스화가 발생한다. C#의 제네릭스는 Java 등과는 달리, 박스화를 최대한 회피할 수 있도록(듯이) 설계되어 있다. 이것은, List<int> 같은 기본적인 사용법은 물론, 인터페이스 제약이 있는 경우에서도 박스화 없이 기능하다. 예를 들면

 

class Container<T> where T : IComparable<T>
{
        
// IComparable<T>.CompareTo를 이용한 무언가
}

 라는 타입이 있다고 하자. 이  T 에

struct Node : IComparable<Node>
{
        
// ...
        
public int CompareTo(Node other) => // ...
}

 

라고 하는 구조체 타입을 대입하여 Container<Node> 라고 하는 타입을 만들어 사용해도, 박스화는 발생하지 않는다. 이것은, 인터페이스가 「제약」으로서만 기능하고, 실제로 인터페이스 타입으로 캐스트 되는 것은 아니기 때문이다. 구조체는 상속할 수 없기 때문에, 인터페이스 메소드 (이 경우 CompareTo)의 호출처는 T에 Node를 대입한 시점에서 정해진다. 따라서 런타임은 인터페이스 메서드 호출을 정적 호출로 바꿀 수 있다.

 

 

stackalloc / Span<T> 사용 (C# 7.2 이상)

작은 배열이라면 stackalloc을 사용하여 스택에 넣을 수 있다. 이전에는 stackalloc를 사용하려면 unsafe 코드 내에서 포인터를 사용해야 했지만 더 이상 필요하지 않다. stackalloc에서 확보한 메모리 블록은

 

Span<int> a = stackalloc int[8];

 

처럼 Span<T>로 받을 수 있다. 이 Span<T> 타입은 통상의 매니지드 배열 또는 그 부분열, 스택상의 배열, 비매니지드 힙상의 메모리 블록을 통일적으로 표현할 수 있는 구조체로 이것을 사용하는 것으로 C++에서 선두 포인터와 배열의 길이를 지정하는 것과 같은 유연성을 얻을 수 있다.

 

덧붙여 Span<T>는 List<T>에서도 생성할 수 있지만, 내부에서 배열의 재확보가 발생하면 무효한 배열을 가리키고 있는 상태가 되기 때문에, System.Runtime.InteropServices.CollectionsMarshal 클래스에 숨겨져 있다.

 

덤: 포인터 사용

C#은 안전과 실행 속도의 균형을 이루는 언어이며 실행 속도는 가능한 한 추구하지만 최우선 사항은 아니다. 하지만 unsafe 선언된 블록 내에서 포인터를 사용할 수 있다. C++가 그렇듯이, 부주의한 포인터의 사용은 복잡하고 괴기한 버그를 발생시킬 가능성이 있으므로, 이 옵션은 마지막 수단으로 해 두는 것이 좋다. 원리적으로는, 포인터를 사용하면 값 타입만으로 참조 같은 것은 모두 할 수 있을 것이다.

 

 

 

C#에서 할 수없는 것

 

값 타입에 참조를 필드로 저장

예를 들어, 아래와 같이 할 수 없다.

struct Node
{
        
public ref int RefToInt;
}

 

또, 아래와 같은 일도 할 수 없다.

struct Node
{
        
public ref Node RefToAnother;
}

 

이것을 할 수 있으면, 각각, 갱신이 필요한 필드의 참조를 취득해 두거나, 정리된 크기의 메모리 블록으로부터 스스로 나누어 GC에 의지하지 않고 참조형 같은 것을 할 수 있을 것이다. 그러나 C#은 C++와 달리 유효하지만 보장할 수 없는 참조를 얻을 수 없다.

위와 같은 긴 수명의 참조가 확실히 유효하고 C#이 보증하는 것은 어렵기 때문에, 이렇게 하는 방법은 할 수 없다. (포인터를 사용하면 비슷한 것을 할 수 있을테지만)

 

구조체의 상속이나 다중 상속은 할 수 없다

제네릭스에서도 썼지만, 구조체는 상속할 수 없다. 따라서 구조체의 상속 관계로 확장하거나 아래의 C++ 처럼 할 수 없다

 

struct Base {
        
virtual void hello() {
                
std::cout << "Hello from Base!" << std::endl;
        }
};

struct Derived : Base {
        
virtual void hello() override {
                
std::cout << "Hello from Derived!" << std::endl;
        }
};

void callHello(Base &b) {
        b.hello();
}

int main() {
        Base b;
        Derived d;
        callHello(b); 
// Hello from Base!
        callHello(d); 
// Hello from Derived!
        
return 0;
}

 

반응형