C 문자열: 리터럴, 포인터, 배열

· by 박승재

C 언어에서 문자열(String)은 말 그대로 문자(char)가 실처럼 연속적으로 나열된 형태를 가지고 있습니다.

문자열의 끝은 \0(=NULL)으로 표시되며 이런 형식의 문자열을 null-terminated string이라 부릅니다. C에서 문자열의 길이를 구하기 위해서는 \0을 찾을 때까지 문자열을 읽어야 하므로 길이 알고리즘은 \(O(n)\)의 시간복잡도를 가지며 이 때문에 러시아 페인트공 알고리즘과 같은 문제점이 발생합니다.

C++에서는 위 문제를 해결하기 위해 배열의 시작 주소와 크기를 저장하기 때문에 std::string::size()는 \(O(1)\)의 시간복잡도를 가집니다.

문자열 리터럴

문자열 리터럴(Literal)의 경우, const char *로 표현되며 Read-Only 메모리에 저장되어 포인터를 통해 읽어올 수 있습니다.

#include <stdio.h>
#include <string.h>

int main() {
    const char* s0 = "hello";
    char* s1 = "hello";
    printf("char size: %zd\n", sizeof(char));
    printf("s0 size: %zd\n", sizeof(s0)); // 포인터(64비트에서 포인터의 크기는 8바이트)
    printf("s0 length: %zd\n", strlen(s0));
    printf("s0 address: %p\n", s0);
    printf("s1 size: %zd\n", sizeof(s1)); // 포인터
    printf("s1 length: %zd\n", strlen(s1));
    printf("s1 address: %p\n", s1); // `s0`와 주소 동일(Release 빌드)
    return 0;
}
char size: 1
s0 size: 8
s0 length: 5
s0 address: 000000000040404C
s1 size: 8
s1 length: 5
s1 address: 000000000040404C

s0s1은 같은 표현으로, s1에는 const가 생략되어 있을 뿐 여전히 수정 불가능한 값입니다.

s0s1포인터sizeof의 결과가 문자열의 길이가 아닌 8바이트(포인터의 크기)가 나오는 것을 확인할 수 있습니다.

대부분의 컴파일러는 문자열 리터럴을 Release 빌드에서 같은 주소 값을 가지도록 최적화됩니다.

문자열과 문자 배열

C에서 문자열과 문자로 이루어진 배열은 다르게 동작합니다.

일반적으로 문자 배열을 문자열이라 부르지만 아래 예시를 위해 구분해 부르겠습니다.

#include <stdio.h>
#include <string.h>

int main() {
    char* s1 = "hello";
    char s2[] = "hello";
    char s3[] = { 'h', 'e', 'l', 'l', 'o', '\0' };
    char s4[] = { 'h', 'e', 'l', 'l', 'o' };
    printf("s1 size: %zd\n", sizeof(s1)); // 포인터
    printf("s1 length: %zd\n", strlen(s1));
    printf("s1 address: %p\n", s1);
    printf("s2 size: %zd\n", sizeof(s2)); // 배열
    printf("s2 length: %zd\n", strlen(s2));
    printf("s2 address: %p\n", s2);
    printf("s3 size: %zd\n", sizeof(s3)); // 배열
    printf("s3 length: %zd\n", strlen(s3));
    printf("s3 address: %p\n", s3);
    printf("s4 size: %zd\n", sizeof(s4)); // 배열
    printf("s4 length: %zd\n", strlen(s4)); // 오작동
    printf("s4 address: %p\n", s4);
    return 0;
}
s1 size: 8
s1 length: 5
s1 address: 000000000040401E
s2 size: 6
s2 length: 5
s2 address: 000000000061FE14
s3 size: 6
s3 length: 5
s3 address: 000000000061FE1A
s4 size: 5
s4 length: 10
s4 address: 000000000061FE0F

s1Read-Only 메모리에 저장된 문자열의 시작 위치를 가리키는 포인터입니다. 위에서 확인한 것처럼 포인터를 sizeof에 넣으면 포인터의 크기(8바이트)가 반환됩니다.

반면, s2, s3, s4스택(Stack) 저장된 배열입니다. 포인터와 다르게 배열은 sizeof에 넣었을 때 배열의 크기(배열 요소의 개수)를 반환받습니다.

s2"hello"는 문자열 리터럴처럼 보이지만 사실 컴파일러에 의해 { 'h', 'e', 'l', 'l', 'o', '\0' } 형태로 치환되는 문법입니다. 즉, s2s3는 같은 구문입니다.

문자 배열을 사용할 때 주의할 점은 '\0'이 마지막 요소가 되어야 한다는 것입니다. s4를 보면 '\0'이 마지막 요소가 아니기 때문에 strlen이 올바른 문자열 길이를 출력하지 못하는 것을 확인할 수 있습니다.

문자열 리터럴과 동적할당된 문자 배열

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

int main() {
    char* s1 = "hello";
    char* s5 = malloc(sizeof(char) * 6);
    strcpy(s5, "hello");
    printf("s1 size: %zd\n", sizeof(s1)); // 포인터
    printf("s1 length: %zd\n", strlen(s1));
    printf("s1 address: %p\n", s1);
    printf("s5 size: %zd\n", sizeof(s5)); // 포인터
    printf("s5 length: %zd\n", strlen(s5));
    printf("s5 address: %p\n", s5);
    return 0;
}
s1 size: 8
s1 length: 5
s1 address: 000000000040401E
s5 size: 8
s5 length: 5
s5 address: 0000000000B21420

문자열 리터럴과 동적할당된 문자 배열은 모두 첫 번째 문자의 주소를 가리키는 포인터로 표현됩니다. s1s5는 모두 포인터이기 때문에 sizeof의 결과 역시 8바이트(64비트 기준)로 같습니다.

#include <stdio.h>

int main() {
    char* s1 = "hello";
    s1[4] = '!';
    printf("s1: %s", s1); // Error
    return 0;
}
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

int main() {
    char* s5 = malloc(sizeof(char) * 6);
    strcpy(s5, "hello");
    s5[4] = '!';
    printf("s5: %s", s5); // "hell!"
    return 0;
}

문자열 리터럴과 동적할당된 문자 배열의 차이는 가변성에 있습니다. 문자열 리터럴은 Read-Only 메모리에 저장되어 수정할 수 없지만 동적할당된 문자 배열은 힙(Heap)에 할당되어 수정할 수 있습니다.

크기가 올바르지 않은 문자 배열

만약 문자열(문자 배열)을 할당할 때 올바른 크기를 지정하지 않는다면 어떤 일이 발생할까요?

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

int main() {
    char s6[5] = "hello";
    char s7[2] = "hello";
    char* s8 = malloc(sizeof(char) * 5);
    strcpy(s8, "hello");
    char* s9 = malloc(sizeof(char) * 2);
    strcpy(s9, "hello");
    printf("s6: %s\n", s6);
    printf("s7: %s\n", s7);
    printf("s8: %s\n", s8);
    printf("s9: %s\n", s9);
    return 0;
}
s6: hello
s7: hehello
s8: hello
s9: hello

컴파일 오류가 발생하지 않고 문자열을 임의로 잘라서 저장합니다.

놀랍게도 hello가 정상적으로 출력되는 것처럼 보이지만 쓰레기값이 출력된 것으로 항상 정상적으로 출력된다는 보장을 할 수 없습니다.