구조체 내부 배열 관련해서 질문드립니다. ㅠ

안녕하세요
c# 초보자 입니당. c++만 하다가 c# 해보는데 어렵네요 ㅎ

일단 궁금한 점이 있습니당

[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Ansi, Pack = 1)]
public struct MY_DATA
{
    public ushort value1;
    public ushort value2;
    [MarshalAs(UnmanagedType.ByValArray, SizeConst = 24)]
    public byte[] name;
}


public MY_DATA[] my_data = new MY_DATA[2];
int size = Marshal.SizeOf(my_data[0]);

위와 같이 했을 경우 size는 28이 나옵니다.
근데 디버깅으로 봤을 때는 my_data[0]이나 [1]이나 name이 null 로 나옵니다.
그래서

Array.Clear(my_data[0], 0, my_data[0].Length);

하면 null 인데 접근했다고 에러가 뜹니다. 당연하겠죠

public MY_DATA[] my_data = new MY_DATA[2];
for( int i = 0 ; i < 2 ; ++i )
{
    my_data[i] = new();
}

이처럼 for문으로 객체를 생성해 보았습니다. 여전히 name은 null이 더군요.

[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Ansi, Pack = 1)]
public MY_DATA
{
    // 생략
    public MY_DATA()
    {
        name = new byte[24];
    }
}

구조체에 생성자를 명시하고 name을 new로 할당했습니다.
이렇게 했더니 name이 정상적으로 24바이트씩 보이더군요.

여기서 궁금한 것이…

public MY_DATA[] my_data = new MY_DATA[2];
int size = Marshal.SizeOf(my_data[0]);

이와 같이 했을 경우에 size가 정상적으로 28바이트가 출력이 되는데
왜 name이 null로 보이나요?
my_data[0].value1이나 value2에 데이터 접근이 가능한 것으로 보아
메모리 어딘가에 할당이 된 것 같은데

for문으로 돌려서 직접 [0]과 [1]에 new로 또 해줘야 하나요?
그리고 구조체 생성자에도 명시를 해야 하구요?

public MY_DATA[] my_data = new MY_DATA[2];

했을때 my_data[0] 이랑

public MY_DATA[] my_data = new MY_DATA[2];
for( int i = 0 ; i < 2 ; ++i )
{
    my_data[i] = new();
}

했을 때 my_data[0] 이랑 다른 주소의 데이터로 짐작이 되는데
제가 생각하는 것이 맞는지 궁금합니다.

두서 없이 질문을 드렸네요.
고수님들 가르침을 부탁드리겠습니다. ㅠ

3 Likes

제 짧은 지식으로 답변을 드리자면

첫 번째 질문은 Marshal.SizeOf의 경우 공식 문서를 참조하시면 아래와 같이 설명되어 있습니다.

“반환되는 크기는 관리되지 않는 형식의 크기입니다. 개체의 관리되지 않는 크기 및 관리되는 크기는 다를 수 있습니다.”

이 말에 따르면 반환되는 값인 28은 구조체인 MY_DATA 자체의 크기를 반환하는 것으로 간주할 수 있으며, 실제 구조체 객체의 크기를 나타내지는 않습니다.

따라서, 별도의 내부 배열 초기화 과정이 없다면 C# 언어 사양에 따라 당연히 null 값이 들어가는 것이 맞습니다.

두 번째 질문은

public MY_DATA[] my_data = new MY_DATA[2];
for( int i = 0 ; i < 2 ; ++i )
{
    my_data[i] = new();
}

이 코드의 경우 for문 안에서 new()를 통해 다시 구조체 객체를 생성하셨기 때문에 for 문 이전에 할당된 객체와는 다른 객체라고 보시면 됩니다.

3 Likes

아래의 코드를 참조해보세요.

using System.Runtime.InteropServices;

byte[] data =
[
    0x01, 0x00, // Value1
    0x02, 0x00, // Value2
    
    // Name
    0x41, 0x42, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4A,
    0x4B, 0x4C, 0x4D, 0x4E, 0x4F, 0x50, 0x51, 0x52, 0x53, 0x54,
    0x55, 0x56, 0x57, 0x58
];
Console.WriteLine(data.Length);

GCHandle handle = GCHandle.Alloc(data, GCHandleType.Pinned);

var myData = Marshal.PtrToStructure<MyData>(handle.AddrOfPinnedObject());

handle.Free();

Console.WriteLine(myData.Value1);
Console.WriteLine(myData.Value2);
Console.WriteLine(myData.Name.Length);
2 Likes

좀 더 기초적인 시선에서 봤는데요… (왜냐하면 제가 초보기 때문이죠 하핫!)

MY_DATA의 "배열"은 선언해서 new로 초기화를 하셨는데
각 배열 마다 의 각각 MY_Data 객체에 대한 초기화는 안 하셨네요.

value type 인 ushort 은 알아서 default 값이 들어갔겠지만
byte[]에 대한 건… 어떻게 활용하시려고 했는지 궁금해졌어요.
MY_Data 객체들이 들어갈 공간은 빌렸는데 (배열선언)
안에 내용물은 없는(null)상태인거죠.
그나마 다행인건 MY_DATA는 struct 타입이라 기본값(default) 생성은 되었지만요.

같은 이유로
두번째 예시 코드에서
my_data[i] = new(); 로 MY_DATA 타입의 초기화는 하셨지만,
byte[]에 대한 초기화는 하지 않으셨으니까요.
요컨대 byte* 선언 만 해 놓고 값 할당을 안 하신 거랑 똑같은 상황이에요.

이게 겉보기에는 같은 문법 []로 표현되는 Array가 C#에선 struct가 아니라고 기억하셔도 될 것 같네요.
오히려 익숙하게 쓰던 활용법은 span<T>에 좀 더 가깝지 않을까… 조심스럽게 예측해봅니다.

4 Likes

이 상황을 이해하기 위해서는 마샬링(Marshaling)이라는 개념을 알아야 합니다.

마샬링은 관리(Managed) 코드와 네이티브(Native/Unmanged) 코드 간에 데이터를 연동할 때 타입을 변환하는 프로세스입니다.
형식 마샬링 - .NET | Microsoft Learn

  • 관리 코드: C#과 같은 언어로 작성된 .NET CLR 상에서 구동 되는 코드
  • 네이티브 코드: C나 C++ 같은 언어로 작성된 CPU 명령으로 직접 컴파일 되는 코드

여기서 가장 중요한 포인트는 관리 코드는 GC와 같은 자체 메모리 관리 기능이 있어 타입 내 데이터 저장 구조가 네이티브 코드와 조금 다르다는 것입니다.

따라서 마샬링은 관리 코드와 네이티브 코드 간에 연동(COM 또는 P/Invoke)이 필요할 때 서로 다르게 저장되는 방식간의 변환 기능을 제공해주는 개념입니다.

위 코드에서 사용하신 StructLayoutMarshalAs, Marshal.SizeOf는 모두 먀살링에 사용되는 요소로 Marshal.SizeOf 함수는 현재 my_data[0] 인스턴스의 실제 크기가 아닌 관리 코드로 작성한 MY_DATA형식이 네이티브 코드가 되었을 때의 크기를 알려주는 함수인 것입니다.

public static int SizeOf(object structure)
{
    if (structure is null) throw new ArgumentNullException(nameof(structure));

    return SizeOfHelper(structure.GetType(), throwIfNotMarshalable: true);
}

위 코드는 Marshal.SizeOf 함수의 구현입니다. 실제 인스턴스의 값은 사용하지 않고 GetType()을 호출하는데만 쓰입니다. Marshal.SizeOf<MY_DATA>() 함수처럼 타입만 고려하는 것입니다.

C/C++에서는 멤버 변수로 지정한 배열은 타입 내에서 직접 공간을 차지하도록 값 형식으로 메모리가 구성되지만 C#에서 배열은 별도의 참조 형식으로 메모리가 구성됩니다.

C/C++에서 작성한 MY_DATA 구조체는 28바이트이지만 C#에서는 사실 2바이트+2바이트+주소 4바이트 총 8바이트인 것이죠.
(32비트 환경에서의 크기이고 64비트에서는 2+2+주소 8바이트로 총 12바이트(혹은 메모리 정렬에 의해 조금 다를 수 있음)

글쓴이 분께서는 관리 코드와 네이티브 코드의 차이점에 대해서 좀 더 공부해 보시면 될 것 같습니다ㅎ

위 코드와 관련된 질문에 대한 답변은 아래 그림과 같이 동일한 주소에 덮어 써 진다 입니다. (정확하게는 my_data[i]의 주소에 대해 생성자가 호출된다.)

다만 다른 점은 구조체 생성자에 name = new byte[24]구문을 추가했다면 new MY_DATA[2]를 실행한 시점에는 각 구조체 인스턴스가 생성자 호출 없이 기본 값으로 초기화 된 default(MY_DATA)와 동일한 상태이고 new()를 호출하면 비로소 생성자가 호출되어 name 변수에 24바이트 배열이 생성되게 됩니다.

질문해주신 내용을 보니 C#에서 참조 형식인 클래스의 인스턴스와 값 형식인 구조체 인스턴스의 차이에 대해서도 공부해 보시면 좋을 것 같습니다.

13 Likes

정확히 원하시는바는 잘 안읽어봤지만 메모리단까지고려한 배열을 사용하는데있어서 최신닷넷의 InlineArray가 대부분의 문제를 해결해줄 수 있을겁니다.

1 Like

와오…
막연하게만 생각하던 부분들을 디테일하게 짚어주셨네요…
저도 많이 배워갑니다 ㅎㅎ
저렇게 설명하려면 역시 내공이 더 필요하겠군요…

2 Likes

답변 감사합니다. ^^

상세한 답변 감사합니다. ^^
가야 할 길이 머네요 ㅎㅎ…

답글을 몇 번을 쓰고 지우고 읽어보고
다시 쓰고 하는지 모르겠네요 ㅎㅎ…

일단 제가 하고자 하는 것을 c++로 풀어보자면

typedef struct _my_data_
{
    uint16_t value1;
    uint16_t value2;
    byte* name;

    _my_data_()
         : value1(0)
         , value2(0)
         , name(nullptr)
    {
        name = new byte[20];
        ::memset(name, 0x00, sizeof(byte) * 20);
    }

    ~_my_data_()
    {
        if( name != nullptr )
        {
            delete [] name;
            name = nullptr;
        }
} MY_DATA;

int main()
{
    MY_DATA* my_data[2] = { 0, };
    for( int i = 0 ; i < 2 ; ++i )
    {
        my_data[i] = new MY_DATA();
    }

    // 생략

    for( int i = 0 ; i < 2 ; ++i )
    {
        if( my_data[i] != nullptr )
        {
            delete my_data[i];
            my_data[i] = nullptr;
        }
    }
}

이 개념으로 c#에서 적용을 해볼려니
배열 생성할때 1번, for문 돌면서 1번
총 new를 2번해야지만 되더라구요.
물론 구조체 생성자에서 name에 대해 메모리 할당을 했습니다.
코드는 아래와 같습니다.

[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Ansi, Pack = 1)]
public struct MY_DATA
{
    public ushort value1;
    public ushort value2;
    [MarshalAs(UnmanagedType.ByValArray, SizeConst = 24)]
    public byte[] name;

    public MY_DATA()
    {
        name = new byte[24];
    }
}


public MY_DATA[] my_data = new MY_DATA[2];
for( int i = 0 ; i < 2 ; ++i )
{
    my_data[i] = new MY_DATA();
}

이러면서 al6uiz님의 답변글을 다시 한번
꼼꼼히 읽어보았습니다.

제가 이해한 것이 맞는지 궁금합니다.

public MY_DATA[] my_data = new MY_DATA[2];

위 내용은 MY_DATA 구조체의 변수들만 생성된 것이고
32비트 환경이라고 가정했을 경우 2 + 2 + 4 해서
1개의 객체가 8바이트인 2개의 배열이니 총 16바이트로 만들어 졌겠네요.
이 시점에서는 구조체 생성자는 호출이 되지 않더군요.
(c++에서는 생성자가 호출이 되는데 왜…?하며 좀 헷갈려 했습니다.)

for( int i = 0 ; i < 2 ; ++i )
{
    my_data[i] = new MY_DATA();
}

그리곤 for문으로 각 인덱스로 접근해서 객체를 생성합니다.
이 부분에서는 생성자가 호출이 되더군요.

근데 여기서 궁금한 것이 윗 부분에서

public MY_DATA[] my_data = new MY_DATA[2];

이렇게 생성한 객체 16바이트에 대한 주소 값을

my_data[i] = new MY_DATA();

으로 다시 8바이트 씩 2번 할당 받아버리면
최초 생성했던 16바이트는 메모리 릭이 아닌가 싶었습니다.

첫번째로 만들었던 my_data[0].value1 과
for문으로 두번째로 만들었던 my_data[0].value1 은
같은 것인가 라는 의문점도 들구요.
new를 2번 했는데 전의 my_data[0].value1과 후의 my_data[0].value1은
다른게 아닌가?

이 부분이 마지막에 언급해주신
참조형식 클래스의 인스턴스와 값 형식인 구조체 인스턴스의 차이점에서
실마리를 얻을 수 있을 것 같은 느낌이네요.

너무 감사합니다…
열심히 파봐야 겠네요 ㅠ

1 Like

위 답변에서 이 부분은 제가 IL 코드를 확인하다 착각을 한 것 같습니다. 죄송합니다 혼돈 없으시길 바랍니다.:sweat_smile:

애초에 값 형식의 인스턴스였기 때문에 메모리 릭은 아닙니다.
스택에 MY_DATA형식 크기 만큼을 예약하고 여기에 생성자 로직을 수행한 뒤, 먼저 만들었던 MY_DATA[i] 위치에 복사를 하는 것이죠.

배열의 길이가 100개라 하더라고 new()를 위한 공간은 스택에 1개만 할당 됩니다.

그러면 이것이 또 같은 객체인가?라는 질문에는 new()의 결과가 이전 배열 요소와 같은 주소에 복사 되었기 때문에 다른 객체이나 같은 위치에 있는 값이다. 라고 할수 있겠네요.

만약 위 케이스에서 루프 내부의 생성자 호출에 의해 100번 복사되는 것이 비효율적이다라고 생각되신다면

public struct MY_DATA
{
    public ushort value1;
    public ushort value2; 
    public byte[] name;

    public void Initialize()
    {
        value1 = value2 = 0;
        name = new byte[24];
    }
}

for( int i = 0 ; i < 2 ; ++i )
{
    my_data[i].Initailize();
}

이렇게 생성자 대신 초기화 함수를 만들어서 호출하는 방법이 있습니다.
(my_data + i)->Initialize() 이런 느낌이죠.

2 Likes
public MY_DATA[] my_data = new MY_DATA[2];

요건 my_data 변수를 생성한 생성한 거예요.
my_data 라는 배열을 가리키는 변수힙에 생성한 거예요. 내부의 item 들을 초기화 한 게 아닙니다.
(배열은 참조형식입니닷)


그리고

for( int i = 0 ; i < 2 ; ++i )
{
    my_data[i] = new MY_DATA();
}

요건 my_data 의 특정 인덱스에 MY_DATA 타입의 item 을 생성하여 할당한 거죠.

3 Likes

보다 정확히는 MY_DATA[ ] 형식의 변수가 MY_DATA[ ] 객체로 초기화된 것입니다.

MY_DATA[ ] 는 C++ 에서는 MY_DATA의 배열이지만, C#에서는 Array<MY_DATA> 형식으로 볼 수 있습니다.
즉, 위의 코드는 아래의 유사 코드로 나타낼 수 있습니다.

Array<MY_DATA> my_data = new Array<MY_DATA>(2);

C# 이 탄생할 때 C 와의 문법적 유사성을 제공하기 위해, Array 가 아닌 T[ ] 형식을 채택한 것 같습니다.

물론, 실제로 C#에는 Array 클래스가 있지만 이는 배열을 생성하기 위한 객체가 아니라, 배열을 관리하는 기능을 모아둔 객체로 위의 유사 코드는 동작하지 않습니다.

중요한 점은, 배열 객체가 초기화될 때(new MY_DATA[2];), 배열의 요소도 함께 초기화된다는 점입니다.

이 배열의 요소의 형식인 MY_DATA 는 struct 이기에, MY_DATA[ ] 의 각 요소는 MY_DATA 형식의 기본값으로 초기화됩니다. (만약 MY_DATA 가 class로 선언되었다면, 모든 요소는 null 로 초기화되었을 것입니다.)

이는, new MY_DATA[ ] 를 수행하는 동안, MY_DATA의 생성자가 묵시적으로 호출됨을 의미합니다.

이때, 사사로이 정의한 MY_DATA 형식의 기본값을 컴파일러가 어떻게 알 수 있을까라는 의문이 생길 것입니다.

그에 대한 대답은 “형식의 모든 멤버가 기본값으로 설정된 상태” 가 그 형식의 기본값이 됩니다.

즉, ushort 형식의 멤버는 0, 참조형 객체인 name 은 null로 초기화됩니다.

배열 객체를 초기화할 때, 그 요소가 되는 객체들이 생성(되고 초기화)됐으므로, 아래의 코드는 각 요소를 한 번 더 초기화하는 (불필요한) 코드에 지나지 않습니다.

따라서, 아래의 질문에도 답이 되겠죠.

참고로, 값 형식의 요소를 한 번 더 초기화한다고 해서, 메모리 릭이 발생하지 않습니다. 그 요소가 차지하고 있는 메모리(변수)에 덮어 쓰기 하는 것이니까요.

그러나, 참조형 객체는 약간 다릅니다. 새로운 객체를 생성하고, 그 객체의 참조값을 요소의 값에 덮어 쓰기 하는데, 기존에 있던 객체는 GC가 청소하기 전까지 메모리에 남아 있게 됩니다.

그렇다고 이를 막무가네로 메모리 릭이라고 볼 수도 없습니다.
GC가 발동될 때, 자동적으로 청소되기에, 누적되지 않으니까요.

청소되지 않고 메모리 릭을 유발하는 변수의 대부분은 static 변수들 - 다시 말하면 전역 변수들입니다. 이들은 프로세스의 수명주기와 함께 하기에, 섣부른 전역 변수 남발이 메모리 릭의 최대 주범이라고 할 수 있습니다.

이 코드는 @HSLee 님이 말씀 하셨던 것 처럼 MY_DATA 구조체를 담을 공간을 할당한 것이고 값 타입인 구조체의 특성상 초기값으로 초기화 된 것입니다. 참조 타입이면 null로 초기화 되고 new()로 실제 객체를 할당하지 않고 접근하면 Null pointer exception이 발생할 것입니다.
또한 생성자가 호출 되지 않았던 이유는 객체의 생성자를 부르는 new()라는 키워드를 사용하지 않았기 때문이고 이 코드로는 MY_DATA의 생성자가 호출 되지는 않습니다.

그래서 @BigSquare 님도 이런 말씀을 하신걸로 보입니다.
다만, 예제에서 보여주신 것 처럼 생성자를 통해 초기화 하고 싶으신 것들이 있다면 new() 키워드를 통해 생성자를 호출해주셔야 합니다.

1 Like