C/C++을 위한 간략 C 강좌

C와 C++ 마스터 3: 구조체 ( struct )

kim선달 2020. 7. 25. 19:25
Select Language

1장과 2장을 통해, 기본 자료형들과 이들로 배열을 만드는 법을 알아보았습니다.

이번장에서는 여러 자료형을 담는 구조체에 대해서 알아보겠습니다.

C 언어를 써 보셨던 분들도 모를 만한 내용들을 적어 두었기 때문에, 반드시 읽어 보시면 도움이 될 것 같습니다.

 

구조체 ( struct )

목차

  1. 선언
  2. 초기화
  3. 메모리
  4. C++ 에서 추가된 점

 

이제까지 배운 자료형들은 모두 같은 타입만 저장할 수 있었습니다.

int는 정수 자료형 1개, int 배열은 정수자료형들만 저장할 수 있습니다.

char*을 사용해 문자열을 이용한다면 숫자를 "100" 이런식으로 나타낼 수는 있겠지만, 만약 그 숫자가 3.14159265359.. 로 이어지는 유리수라면 문자열로 숫자를 저장 한다는것이 얼마나 비효율적인지 감이 오실겁니다.

 

그래서 만약 어떠한 물건의 이름과 가격을 저장해야 한다면, 다음과 같이 두 개의 배열에 나누어서 저장해야 합니다.

char* name[] = {"apple", "banana", "kiwi"};
int price[] = {1000, 1500, 700};

for(int i=0; i<3; ++i)
	printf("%d번째 물건과 가격: %s %d", name[i], price[i]);

 

하지만 원래 "apple"-1000, "banana"-1500 이런 식으로 의미가 있는데, 따로 저장하니 뭔가 동떨어진 느낌입니다.

그렇게 느껴지시지 않는다고요? 그렇다면 저기에 가전제품의 목록과 가격을 추가하고, 주방용품, 육류, 생선 등등을 추가해 봅시다.

벌써 배열만 10개가 넘어가게 되고, 한눈에 봤을 때 어떤 값들이 연관있는지 잘 들어오지 않게 됩니다.

실수로 가전제품의 이름과 생선의 가격을 인덱스로 출력했다면 컴파일은 정상적으로 되지만, 만약 배열 길이가 같지 않았다면 컴파일 시에는 오류가 없지만, 런타임에서 잘못된 메모리 엑세스 오류가 나게 됩니다.

 

보통 C나 C++에서 좋은 해결법이란 컴파일 시에 에러가 잡히는 방법을 말합니다.

그래서 실수로 위와 같은 오류가 났는데, 이 오류가 컴파일 시에 잡히지 않으면 이는 좋은 코드가 아닌 것입니다(3을 곱할걸 4를 곱하는 오류는.. 그냥 프로그래머가 잘 해야 합니다).

잡설이 길었는데, 이러한 이유들을 설명하는건 왜 이런걸 써야하나? 에 대한 의문을 확실하게 해소하기 위함입니다.

 

1. 구조체 선언

위에서 말한 연관되어있는 서로 다른 자료형들을 한번에 담을 수 있는것이 바로 구조체(struct)입니다.

구조체의 선언은 다음과 같습니다.

struct Fruit { 
    char* name;
    int price;
};

이때 구조체의 자료형은 Fruit이 아닌 struct Fruit 입니다. 변수를 선언할 때 자료형을 그냥 Fruit으로 쓰면 오류가 납니다.

 

2. 구조체 초기화

struct Fruit fruit1;
struct Fruit fruit2 = {"apple", 100};
struct Fruit fruit3 = {"banana"}; // price 는 0

struct Fruit fruit_array[] = {{"apple", 100}, {"banana", 200"}};

fruit1 처럼 구조체를 초기화 하지 않으면 구조체의 모든 멤버는 초기화 되지 않습니다.

다만, 멤버를 하나라도 초기화 한다면 나머지 멤버가 모두 0으로 초기화 됩니다.

나머지 멤버 중 정적 배열이 있다면, 그 배열의 모든 원소가 0으로 초기화 됩니다.

이는 C및 C++표준입니다.

구조체 배열은 배열을 뜻하는 중괄호 안에 각 구조체 원소들을 중괄호로 선언하면 됩니다.

 

 

구조체 특정 멤버를 집어서 초기화 할 수도 있습니다.

struct Fruit fruit1 = {"apple", 100};
struct Fruit fruit2 = {.price=100, .name="apple"};

struct Fruit fruit3 = {.name="apple", 100}; // name 다음 값을 100으로 초기화
struct Fruit fruit4 = {.price = 100, "apple"}; // 컴파일 에러!; price 다음 값은 없음
struct Fruit fruit5 = {.name="apple" }; // price 는 0으로 초기화
  • fruit1 은 struct Fruit의 명세 순서대로 각각 name, price를 "apple"(의 주소)와 100으로 초기화 합니다.
  • fruit2 처럼 특정 멤버를 집어서 초기화 하는 경우, 순서는 상관 없습니다.
  • fruit3 처럼 일부 멤버만 집어서 초기화 하는 경우, 그 뒤에 오는 값은 그 다음 순서의 멤버를 초기화 하게 됩니다.
    fruit3은 name 다음 순서인 price 를 100으로 초기화 하게 됩니다.
  • fruit4 는 price 다음에 멤버가 아무 것도 없으므로, 컴파일 에러가 나게 됩니다.
  • fruit5는 name만 초기화 해 주었는데, 표준에 따라 초기화 하지 않은 구조체 멤버는 모두 0으로 초기화 되므로, price는 자동으로 0으로 초기화 됩니다.

 

보통 1번째와 2번째 방법(모든 원소를 집어서 초기화)만 쓰이고, 나머지는 한 눈에 알아보기 어려워 쓰지 않는 것이 권고됩니다.

구조체 안의 배열도 특정 인덱스 이후 순서대로 초기화 하는 방법도 있는데, 알아보기 힘들어지기만 할 뿐이니 생략하겠습니다.

 

* 참고로 이 방법은 C언어 표준이고, C++에서는 C++20부터 제한적으로만 적용됩니다. GCC 및 Clang 에서는 C++20 이전 버전에서도 위 문법을 지원합니다.

 

 

구조체 자료형들을 타이핑 하다 보니까 손이 매우 아픕니다.

Fruit이면 누가 봐도 구조체로 선언된것을 말하는것 아닙니까.. 굳이 앞에 struct를 붙이는것은 낭비처럼 보입니다.

이때 typedef 를 이용할 수 있습니다.

구조체는 특별한 녀석이라서 선언과 동시에 별칭 선언을 해 줄 수 있습니다.

typedef struct _Fruit{
    char* name;
    int price;
} Fruit;

struct _Fruit fruit1;
Fruit fruit2;

// 제자리 typedef
typedef struct {
    char* name;
    int price;
} NewFruit;

NewFruit newFruit;

위 예시 처럼 typedef를 사용하는 방법은 2가지입니다. 

 

구조체 안에 구조체를 선언 할 수 있습니다.

대신 C에서는 구조체 안의 구조체에는 typedef를 사용하지 못 합니다.

typedef struct{
    struct inner { // C 는 구조체 안에서는 typedef 를 쓰지 못 함!
        int x;
        int y;
    };
    
    int p;
    struct inner q;
    
} outer;


outer o = {0, 1, 2};

 

 

구조체 안의 구조체를 익명으로 선언할 수 있습니다. 밖에서는 그냥 바로 접근 가능합니다.

근데 사실 익명 공용체면 몰라도, 익명 구조체는 쓸모가 하나도 없습니다. 그냥 잊어버리세요.

typedef struct {
    int x;
    struct {
    	int y;
        int z;
    };
} Struct;

Struct s;

s.x = 3;
s.y = 4;
s.z = 5;

 

3. 구조체의 메모리

구조체는 여러 타입의 자료형들을 담을 수 있습니다.

그럼 4byte 인 int 자료형을 3개 담은 구조체는 크기가 몇 바이트일까요?

12byte가 됩니다. 너무 당연한가요?

그럼 1byte 인 char 자료형을 3개 담은 구조체는 크기가 3byte가 될까요? 맞습니다.

그러면 4byte 인 int 자료형 1개와 1byte 인 char 자료형 1개를 담은 구조체는 크기가 어떻게 될까요?

정답은 8byte 입니다.

 

C와 C++ 에서는 구조체의 메모리 효율성을 위해, 구조체의 크기를 크기가 가장 큰 멤버의 크기 배수로 올림해 버립니다.

1byte char 자료형과 4byte int 자료형은 총 합은 5byte 이지만, 4byte의 배수인 8byte 로 올림해버리고, 나머지 3byte는 그냥 버리게 됩니다.

이를 Data Structure Alignment 라고 합니다.

그래서 빈 공간이 있는 구조체 배열에 뭔가를 memcpy 등 연속적인 메모리를 다루는 연산을 하면 오류가 나게 되니 조심하셔야 합니다.

이는 C/C++ 표준입니다.

빈 공간을 없에주는 표준은 없습니다.

 

다만, 구조체 멤버의 비트를 제한할 수 있는데, 그러면 특정한 규칙에 따라 빈 공간이 줄어들 수 도 있습니다.

typedef struct{
    unsigned int x : 3; // x가 3비트로 제한됨
    unsigned int y;
} data;


data d = {7, 7};
++d.x;
++d.y;

printf("%d %d", d.x, d.y);

결과는

0, 8

x의 타입이 unsigned int이지만, 크기가 3비트로 제한되었기 때문에 표현 가능한 범위는 0에서 7 뿐입니다.

불필요한 크기를 줄일 때 쓰거나 비트 정보를 담을 때 사용합니다.

 

비트를 줄였을 때, 실제로 사용하는 크기들의 합이 원래 자료형의 크기보다 작다면 그 멤버들은 원래 자료형이 차지하던 크기 안에 모두 들어가게 됩니다.

아래는 int가 32비트인 OS에서의 예시입니다.

typedef struct {
    int x : 16;
    int y : 16;
}foo;

typedef struct {
    int x : 16;
    int y : 17;
}bar;

typedef struct {
    int x : 1;
    int y : 1;
    int z : 1;
}faa;

printf("%d\n", sizeof(foo)); // 4 출력
printf("%d\n", sizeof(bar)); // 8 출력
printf("%d\n", sizeof(faa)); // 4 출력

 

그림으로 보면 더 이해가 빠르실 겁니다.

 

bar같은 경우, 빈공간이 거의 절반에 육박합니다.

아까 말했듯이, 이를 없애는 표준은 없지만, 컴파일러마다 제공해 주는 attribute 명령어가 있을 수 도 있으니, 정 필요하시다면 이를 사용하시면 됩니다.

 

GCC 계열 컴파일러는 다음 명령어로 불필요한 padding을 줄일 수 있습니다.

typedef struct {
    unsigned int x : 3;
    unsigned int y;
} data1;

typedef struct __attribute__((__packed__)){
    unsigned int x : 3;
    unsigned int y;
} data2;

printf("%ld %ld", sizeof(data1), sizeof(data2));

결과는

8 5

 

 

또 한 가지 신기한 것은 구조체의 대입은 안의 멤버가 배열이더라도 모든 멤버가 복사가 일어나게 됩니다.

typedef struct {
    int arr[5];
} data;

data d = {1,2,3,4,5};
data d2 = d;  // 복사가 일어남

물론 멤버가 포인터로 할당된 배열이라면 포인터만 복사가 일어나서 같은 배열을 가리키게 됩니다.

그래서 복사하는 함수를 따로 만들어 일일이 해주지 않으면 메모리 해제를 두 번 하게 되는 double free 오류가 생기게 됩니다.

C++에서는 여러 패턴으로 이를 방지할 수 있습니다.

 

 

 

C++에서는 구조체에 typedef 를 붙이지 않아도 되고, struct 명시 없이 바로 쓸 수 있습니다.

struct outer{
    struct inner{
        int x;
        int y;
    };
    
    int p;
    struct inner q;
    
};


outer o = {0, 1, 2};

그 외에도 C++ 에서는 구조체 안에 함수를 넣을 수 있습니다! 그게 곧 클래스를 뜻하는 것이죠.

클래스화 된 구조체는 모든 멤버가 public 인 클래스입니다. 더 자세한 얘기는 C++에서 다루도록 하겠습니다.

함수 없이 작성된 구조체는 C와 기능이 똑같습니다.

 

요약

구조체는 서로 다른 타입들도 담을 수 있다

구조체를 빈 중괄호로 초기화 하거나, 명시되지 않은 값은 0으로 초기화된다

구조체의 대입은 안의 변수가 배열일지라도 모든 값들이 복사된다. 단, 포인터에 할당된 배열은 얕은 복사(swallow copy)가 이루어지니 조심해야 한다.

구조체의 크기는 가장 큰 타입 크기의 배수로 정해진다. 컴파일러마다 이를 바꿀 수 있는 옵션을 주는 경우가 가 있고, 이를 바꾸는 표준은 없다.

구조체 멤버의 비트수에 제한을 줄 수 있다. 이로 인해 구조체의 크기가 줄어들 수 있다.

구조체를 익명으로 선언할 수 있다

C++ 에서는 typedef 없이 만들 수 있다.