C에서의 문자열 배열과 문자열 배열을 동적으로 할당하기

문자열의 배열을 2차원배열을 쓸 때의 문제점

C언어는 문자열을 다루는 자료형이 없다. 대신에  C에서 문자열은 널 문자로 끝나는 문자(char) 타입의 배열을 사용하여 저장한다.  만약 일련의 문자열 집합을 다루려면 문자열의 배열을 써야 할테다.  여러 개의 문자열을 배열에 담고 싶다면 이차원 배열을 쓰는 방법을 생각할 수 있는데,  이 경우 크기가 일정하게 고정된 영역을 여러 개 생성해야 하고, 만약 각 문자열의 길이가 제각각 다르다면 불필요하게 낭비되는 메모리가 제법 될 수 있다.

2차원 배열의 문자열 배열
2차원 배열을 사용하여 구성한 문자열 배열

메모리를 비롯하여, 부족한 하드웨어 자원을 알뜰 살뜰 아껴서 최대의 성능을 내기 위해 개발된 초기 C언어 관점에서 이러한 2차원 배열의 사용은 그리 추천하지 않는다.

문자열은 char 타입의 배열로 나타낼 수 있고, 이는 char 타입 포인터로 대체하여 표현 가능하다. 따라서 문자열의 문자열은 char*타입의 배열이나 char 타입의 2중 포인터인 char** 타입을 써서 메모리 낭비 문제를 해결할 수 있다.

문자열 포인터의 배열

다음 코드는 일련의 정적 문자열을 2차원 배열 대신에, 문자배열의 포인터를 이용해서 정의했다.  이는 문자열의 최대 길이로 각 요소의 길이가 고정되는 2차원 배열과는 달리, 개별 문자열의 시작위치에 대한 포인터들의 배열이며, 따라서 사용되지 않고 남는 메모리가 없다.

연속된 메모리 공간 내의 문자열 배열
연속된 메모리 공간을 사용하는 문자열 배열

이를 확인하기 위해서 p 라는 포인터를 이용해서 전체 내용을 출력해보도록 한다. 하나의 문자열은 널 문자를 통해서 종결이 확인되므로, p가 가리키는 값이 널 문자인 경우에는 줄바꿈 문자를 출력해보도록 했다.

#include <stdio.h>

int main(){
    char *arrs[] = {"apple", "banana", "cherry", "pomegrante", "pepper", "onion" };
    int i;
    char* p = arrs[0];
    for(i=0; i < 40; i++){
        if (*p != '\0') {
            printf("%c", *p);
        } else {
            printf("\n");
        }
        p++;
    }
    return 0;
}

동적으로 할당되는 배열 사용하기

만약에 출석부와 같은 자료를 다루는 코드를 작성한다고 해보자.  물론 우리나라에서라면 학생의 이름은 거의 3글자에서 4글자가 되겠지만, 영어권이라면 좀 상황이 다를 수 있다. 따라서 학생 이름을 char[][] 타입으로 지정하는 것은 상당한 메모리 낭비가 될 수 있다. 특히 한 반의 학생의 수가 많을 수도 있고, 적을 수도 있는 상황이라면 미리 고정 크기의 메모리를 할당하는 것은 비효율적으로 자원을 사용하는 셈이 된다.

또한 위의 예처럼 연속적인 공간에 문자열을 계속해서 저장하는 경우에 추가적인 레코드 삽입으로 더 많은 연속 메모리가 필요한 경우에 메모리 재할당을 요청하는 것은 성능의 영향을 줄 수 있다. 연속적인 메모리를 큰 용량으로 할당하는 것은 메모리 크기가 커지면 커질수록 높은 비용이 요구되는 작업이기 때문이다.

따라서 동적으로 커지는 문자열 배열을 사용하기 위해서는 다음의 사항을 고려한다.

  1. 기본적으로 “문자열 배열”은 char** 타입으로 지정하고, 기본적으로 문자열의 적정 개수 혹은 최소 개수에서 시작한다.  그리고 초기 상태에서 문자열을 저장하기 위한 메모리 공간은 전혀 할당하지 않은 상태로 시작한다.
  2. 문자열 배열에 새로운 문자열을 삽입하려면, 삽입하고자 하는 문자열을 저장할 메모리 공간을 동적으로 할당하고, 이 영역으로 문자열을 복사한다. 그리고 문자열 배열의 i 번째 칸의 값은 새롭게 할당한 지점의 포인터를 저장한다.
  3. 이와 같은 방식으로 배열을 다루면, 배열 자체는 문자열 포인터를 저장하는 연속적인 구간이 된다. 그리고 각 포인터가 가리키는 곳은 별도로 할당된 메모리 영역이며, 이 경우 문자열을 사용하는 것에 대해서는 실제 2차원 배열 혹은 연속적인 문자열 영역과는 아무런 차이가 없을 수 있다.

다음 코드는 이러한 방식을 적용하여 필요한 만큼의 동적 메모리를 그때 그때 할당하여 사용한다. 따라서 문자열의 길이가 얼마가 됐든지에도 무관하며, 배열을 선언한 크기보다 작은 개수의 문자열만 쓰는 경우에도 메모리 낭비를 최소화할 수 있다.

#include <stdio.h>
#include <stdlib.h> // 메모리 할당 함수를 위한 헤더
#include <string.h>  // 문자열의 길이 및 문자열 복사를 위한 헤더

int main(){
    int arraySize = 4; // 문자열 배열의 크기
    char * names[arraySize];
    char buffer[30]; // 입력을 위한 버퍼. 입력받는 이름의 최대 길이는 99바이트 이내
    int n = 0;
    int l = 0;

    while ( n < arraySize ) { 
        scanf("%s", buffer); 
        // 버퍼에 문자열을 입력받은 후, 그 길이 만큼 새 문자열을 위한 메모리를 할당한다.
        // 새로 할당된 메모리에 버퍼의 내용을 복사하고, 그 시작 위치를 배열에 추가한다. 
        l = strlen(buffer); 
        if (l > 0){
            char* newstrptr = (char*)malloc(sizeof(char) * (l + 1));
            strcpy(newstrptr, buffer);
            names[n] =  newstrptr;
            n++;
        } else {
            break;
        }
    }

    // 배열의 각 원소를 순회하면서, 해당 값의 메모리 번지에서 시작되는
    // 문자열을 출력한다. 출력한 후에는 해당 메모리를 해제하여 파괴한다. 
    for(n=0;n<arraySize;n++){
        printf("%02d: %s\n", n, names[n]);
        free(names[n]);
    }
    return 0;
}

이 때의 메모리 현황은 굳이 그림을 표현하자면 아래와 같다.

동적할당된 문자열의 포인터 배열
동적으로 할당되어 분산된 위치에 있는 문자열들을 참조하는 문자열 배열

앞서 표현한 것과 같이 파란색으로 표시한 영역만큼만 최초에 정적으로 할당하고, 새로운 문자열을 저장할 필요가 있을 때, 동적영역에서 해당 문자열 크기만큼의 메모리만 할당하여 내용을 저장한다. 그리고 원래의 문자열 배열은 각각의 원소에 해당하는 문자열의 시작 번지를 저장하게 된다.

사실 이차원 배열은 {행} X {열}의 형태로 메모리를 정의하고 참조하기 때문에 쓰면서 엄청 헷갈리기 쉽다. 따라서 문자열 배열이 아닌 정수값의 격자 형태를 다루는 경우에도 가능하면 1차원배열을 써서 참조하는 것이 정신건강에도 좋을 것인데, 이에 대해서는 다음 기회에 한 번 살펴보도록 하자.