[CS] TIL 15. 문자열, 문자열 비교, 문자열 복사


  1. CS50라이브러리에 string이 어떻게 정의되었는지 설명할 수 있다.
  2. 문자열이 저장되어 있는 방식에 근거해서 문자열을 비교하는 방법에 대해 설명할 수 있다.
  3. 메모리 할당을 통해 문자열을 복사하지 않고, 단순히 문자열의 주소만 복사했을 때는 어떤 문제가 생길까?


boostcourse모두를 위한 컴퓨터 과학 (CS50 2019) 강의를 듣고 정리한 필기입니다. 😀


1. 문자열(string)

1.1 문자열이란?

지금까지 작성한 프로그램 대부분은 사용자에게 어떤 글자를 입력받아 그 값을 사용했다. 그리고 문자열을 저장하기 위해서 CS50 라이브러리에 포함된 string 자료형을 사용하였다.

string s = EMMA;

“EMMA”라는 문자열을 변수 s에 저장하면 컴퓨터 안에서 어떻게 보일까? 메모리 속 어딘가에 5바이트 공간에 저장되어있을 것이다. 왜 “EMMA”는 네 글자인데 5바이트의 공간을 차지할까?

title

그건 E M M A 그리고 끝에 종단 문자가 있기 때문이다. 널 종단 문자인 \0는 0으로 이루어진 바이트(8개의 0비트)로, 문자열의 끝을 구별한다. EMMA는 이런 식으로 메모리에 저장되는데, EMMA를 저장한 변수는 s이다. 만약, 우리가 이 문자열을 조작하고자 한다면 s[0], s[1], s[2], …를 사용해서 개별 문자(E, M, M, A)에 접근해야 할 것이다. 이런 것들을 미루어보아, 문자열은 문자의 배열이고, s[0], s[1], s[2], … 과 같은 하나의 문자가 배열의 한 부분을 나타낸다.

title

하지만 어제 포인터에서 공부했듯이 바이트는 고유의 조소를 가지고 있다. 예를 들어 E의 위치는 0x123이고, M은 0x124, 또 다른 M은 0x125, A는 0x126, 종단 문자 \0은 0x127이라고 하자. 문자열은 문자 하나하나가 계속 이어지는 형태이기 때문에 메모리에 저장될 때도, 메모리의 주소가 시작부터 종단 문자까지 이어진다.

title

그렇게 되면 변수 s문자열을 가리키는 포인터가 되는 것이다. 그리고 s에는 0x123이라는 값이 저장되어있다. 즉, 변수 s는 문자열의 가장 첫 번째 문자이자 주소 0x123에 있는 s[0]를 가리키게 된다. TIL14에서 2.2 그림으로 알아보기를 떠올리면 쉽다.

그렇다면 위에서 말했던 ‘널 종단 문자가 문자열의 끝을 구별한다‘는 말은 무슨 뜻일까? 말 그대로 컴퓨터는 EMMA의 이름이 어디서 끝나는지를, 널 종단 문자(\0)로 안다. 하지만 문자열로 불리던 s는 M M A나 널 종단 문자에 대해서는 전혀 모른다. s는 정말로 첫 번째 문자가 있는 0x123이라는 주소만 안다는 것이다. 문자열 첫 번째 글자만을 가리키면 널 종단 문자를 마주칠 때까지 루프를 돌면서 끝을 알아낸다.

여기까지 공부했을 때 우리가 알아야 할 것은 문자열 같은 건 없다는 것이다.

1.2 char *

int n = 50;
int *p = &n;

포인터에 대해서 다뤘던 TIL14에서 변수 n에 50을 집어넣고, p라는 변수를 만들어 n을 저장했다. 이건 정수의 주소를 저장하는 형태이다.

string s = "EMMA";

위 코드는 우리가 CS50 라이브러리를 통해 string이라는 자료형을 사용한 것이다. 계속해서 말했듯이 문자열(string)은 문자들의 나열이다. 따라서 문자열은 문자 배열의 첫 번째 바이트 주소가 되며, 마지막 바이트는 0을 저장해 그 끝을 알려준다. 그렇다면 string이라는 자료형을 사용하지 않고, 문자의 주소 값을 저장하려면 어떻게 해야 할까?

char *s = "EMMA";

char *를 사용해 저장할 수 있다. *은 주소를 나타내는 것이고, char은 주소에 있는 값의 자료형이 char이라는 말이다. int *을 이용해서 50이 저장된 변수 np라는 변수에 저장한 것처럼, 문자는 char *을 이용해 문자를 가리키는 주소를 저장할 수 있다.

typedef struct
{
    string name;
    string number;
}
person;

우리는 TIL11에서 typedef에 대해서 배웠다. typedef에 대해서 빠르게 훑으면, 새로운 타입을 정의한다는 말로써, 우리는 이것을 이용해 C에는 없지만 우리들의 프로그램에는 존재하는 자료형을 만들 수 있다. 위 코드처럼 이름이나 숫자와 같은 다양한 변수를 하나로 묶어서 person이라는 자료형을 선언할 수 있었다. typedef는 사실 이것보다도 훨씬 더 간단하게 사용할 수 있다.

typedef char *string

위 코드에서 typedef는 새로운 자료형을 선언한다는 의미이고 char *는 이 값의 형태가 문자의 주소가 될 것이라는 의미이다. 그리고 마지막에 있는 string은 자료형의 이름을 의미한다. 실제로 CS50 라이브러리의 헤더 파일에 위와 똑같은 코드가 적혀 있다.

즉, 우리가 사용하던 string이라는 자료형은 char *과 동일하며, 이것은 일종의 추상화이다. 문자의 나열을 주소 하나로 나타낼 수 있다는 사실을 단순화시킨 것이다.

  1. string 자료형을 이용하여 “EMMA” 출력

    #include <cs50.h>
    #include <stdio.h>
       
    int main(void)
    {
        string s = "EMMA";
        printf("%s\n", s); // EMMA
    }
    
  2. char 포인터를 이용하여 “EMMA” 출력

    #include <stdio.h>
       
    int main(void)
    {
        char *s = "EMMA";
        printf("%s\n", s); // EMMA
    }
    

    2번 코드의 char *s에서 s라는 변수는 문자에 대한 포인터가 되고, “EMMA”라는 문자열의 가장 첫 번째 값을 저장한다.

  3. char 포인터를 사용하여 EMMA 이름의 첫 글자 주소를 가지고 오기.

    #include <stdio.h>
       
    int main(void)
    {
        char *s = "EMMA";
        printf ("%p\n", s);     // 0x42aa02
        printf ("%p\n", &s[0]); // 0x42aa02  
    }
    

    위 코드에서 printf의 값이 모두 같은 것을 알 수 있다. 이는 s에 저장된 첫 번째 글자의 주소를 가지고 왔기 때문에 M M A의 주소를 출력할 경우 s에 저장된 주소값과 다른 값이 출력된다. (하지만 E는 0x42aa02 , M은 0x42aa03, M은 0x42aa04, A는 0x42aa05가 될 것이다. 즉, 주소 값이 연속한다는 것이다.)



2. 문자열 비교

 #include <stdio.h>
 
 int main(void)
 {
     char *s = "EMMA";
     printf ("%p\n", s);     // 0x42aa02
     printf ("%p\n", &s[0]); // 0x42aa02
     printf ("%p\n", &s[1]); // 0x42aa03
     printf ("%p\n", &s[2]); // 0x42aa04
     printf ("%p\n", &s[3]); // 0x42aa05
 }

우외 같이 각 문자의 주소값을 출력하는 과정이 주소단위로 이루어진다면 어떻게 될까?

2.1 포인터 연산

#include <stdio.h>

int main(void)
{
    char *s = "EMMA";
    printf("%c\n", *s); // E
}

s에 무엇이 있는지를 출력해보면 EMMA라는 문자열의 첫 번째 값인 “E”에 해당하는 메모리 주소를 출력하게 될 것이다. s가 이름 첫 글자의 주소라면 *s는 그 문자로 가달라는 것이다. 때문에 출력값은 주소값이 0x42aa02이 아니라, E라는 문자가 된다. 해단 주소로 가서 그 내용물을 출력하기 때문이다.

#include <stdio.h>

int main(void)
{
    char *s = "EMMA";
    printf("%c\n", *s); // E
    printf("%c\n", *(s+1)) // M
}

M을 출력하고 싶으면 *s[1]이 아니라, 1을 더해주면 된다. E의 주소값(0x42aa02)에 1을 더해서 0x42aa03(M)에 접근하는 것이다.

printf("%p\n", &s[0]); // *(s+0) E의 주소값
printf("%p\n", &s[1]); // *(s+1) M의 주소값
printf("%p\n", &s[2]); // *(s+2) M의 주소값
printf("%p\n", &s[3]); // *(s+3) A의 주소값

그렇다면 위 코드에서 쓰인 대괄호는 무슨 의미일까. 컴퓨터과학에서는 구문설탕이라고 한다. s[0]s[1]처럼 적으면 컴파일러가 대괄호 표현식을 *(s+1)와 같은 형태로 바꿔준다. 우리가 연산하는 것이 아니라 컴퓨터 내부에서 연산하는 것이다.

위 코드는 아래 코드와 결과값이 정확히 같다.

printf("%c\n", *s);
printf("%c\n", *(s+1));
printf("%c\n", *(s+2));
printf("%c\n", *(s+3));

## 2.2 문자열 비교

문자열을 비교하는 방법을 알아보기 전에 두 정수를 비교하는 방법을 먼저 알아볼 것이다.

#include <cs50.h>
#include <stdio.h>

int main(void)
{
    int i = get_int("j : ");
    int j = get_int("i : ");
    
    if (i == j) 
    {
        printf("Same\n")
    }
    else
    {
        printf("Different\n")
    }
}

정수 i와 정수 j를 사용자에게 입력받아, 두 수가 같으면 “Same”을 다르다면 “Different”를 출력하는 프로그램이다. 컴파일 후 실행하여 i에는 1을 j에는 2를 입력하면 Different가 나올 것이고, ij 모두 1을 입력해주면 Same이 출력될 것이다. 무언가를 비교하고 싶을 때 이렇게 작성하면 된다. 이제 숫자 말고 다른 것을 비교해볼 것이다.

#include <cs50.h>
#include <stdio.h>

int main(void)
{
    // 사용자로부터 s와 t 두 개의 문자열 입력받아 저장
    string s = get_string("s: ");
    string t = get_string("t: ");

    // 두 문자열을 비교 (각 문자들을 비교)
    if (s == t)
    {
        printf("Same\n");
    }
    else
    {
        printf("Different\n");
    }
}

위는 사용자로부터 문자열을 입력받아 비교하는 것이다. 실행해보면 알겠지만, st에 똑같이 EMMA를 입력해도 Different라는 문장이 출력될 것이다. Emma, emma 모두 같은 Different를 출력한다. 왜 다르다고 이야기를 할까? 이유는 각 변수가 저장된 주소가 다르기 때문이다.

title

엄밀히 말하면 위 프로그램이 비교한 것은 두 변수의 주소이다.(stringchar *로 바꿀 수 있다는 것을 기억하자. char *s는 변수 s에 문자의 주소를 저장한다는 의미이다.) 정확한 비교를 위해서는 실제 문자열이 저장되어있는 곳으로 이동하여 각 문제를 하나하나 비교해야 할 것이다.



3. 문자열 복사

사용자로부터 문자열을 입력받아서 복사한 뒤 문자열을 대문자로 바꾸는 프로그램을 만들어보자.

#include <cs50.h>
#include <ctype.h>
#include <stdio.H>

int main (void)
{
    string s = get_string("s : "); // 사용자에게 문자열을 입력받는다.
    string t = s; // 입력받은 문자열을 새 변수에 복사한다. 
    
    t[0] = toupper(t[o]); //toupper은 대문자로 바꾸는 함수로써 ctype.h 라이브러리가 필요
    
    printf("%s\n", s); // 사용자에게 입력받은 문자열
    printf("%s\n", t); // 대문자료 바꾼 문자열
}

위 프로그램을 실행시켜 emma를 입력하면, 우리의 예상과는 달리 printf("%s\n", s)printf("%s\n", t) 둘 다 Emma를 출력한다. 분면 touppert에만 해줬음에도 s까지 대문자로 바뀌는 이유는 무엇일까?

title

string t = s;를 통해 t라는 변수에 s를 복사했는데, s는 사용자가 입력한 문자열이 아니라, 메모리의 주소가 저장되어있다. (string schar *s는 같은 의미다.) t라는 두 번째 변수를 만들어 s를 대입하면 s 안에 있던 화살표를 복사해서 t에 저장하는 꼴이 되는 것이다. 즉, 위 그림처럼 같은 곳을 가리키게 된다. 그렇다면 입력받은 문자열을 복사하려면 어떻게 해야 할까? 메모리를 추가로 사용해서 입력받은 문자열과 동일한 크기의 변수를 만들고 s가 가리키는 글자를 하나씩 복사해 t에 연결시키면 될 것이다.

3.1 메모리 할당 함수(malloc)

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

int main(void)
{
    char *s = get_string("s: ");
    char *t = malloc(strlen(s) + 1);

    for (int i = 0, n = strlen(s); i < n + 1; i++)
        // for (int i = 0; i <  strlen(s) + 1; i++) 라고 할 수도 있지만, 
        // 동일한 질문을 반복하는 것은 비효율적이다.
    {
        t[i] = s[i];
    }

    t[0] = toupper(t[0]);

    printf("s: %s\n", s);
    printf("t: %s\n", t);
}

두 문자열을 실제로 메모리상에서 복사하려면 위코드와 같이 메모리 할당 함수(malloc)를 사용해야한다. malloc가 인자로 받은 것은 할당받을 메모리의 크기이다. 그래서 strlen(s) + 1를 인자로 적어주었다. s의 길이에 1을 더해준 이유는 널 종단 문자가 있기 때문이다. (예를들면 emma를 입력받아 메모리에 저장하면 4바이트가 아니라 5바이트가 필요하다. ) 그리고 루프를 사용해 s가 가리키는 문자열 배열을 하나하나 t에 복사해주면 된다. 이 코드를 컴파일 후 실행시키고 입력값으로 “emma”를 주면 우리가 예상한 대로 s는 “emma”가, t는 “Emma”가 출력되게 된다.

3.2 문자열 복사 함수(strcpy)

문자열 복사는 자주 하는 작업이기 때문에 루프를 직접 할 필요는 없다.

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

int main(void)
{
    char *s = get_string("s: ");
    char *t = malloc(strlen(s) + 1);

    strcpy(t, s)

    t[0] = toupper(t[0]);

    printf("s: %s\n", s);
    printf("t: %s\n", t);
}

3.3 질문

Q1. malloc를 사용하면 요청한 바이트 크기만 할당하고 위치는 신경 쓰지 않는가?

malloc는 요청 크기를 할당할 뿐 위치는 중요하지 않다. 주소를 받아 저장하면 C 코드를 사용해서 그 위치로 언제든지 알 수 있기 때문에 우리 또한 위치는 신경 쓸 필요가 없다.

Q2. 마지막 종단 문자를 복사하지 않으면 어떻게 될까?

▶아무도 모른다. st를 출력하고 할 때, 종단 문자가 없다면 그 뒤 내용까지 다 출력하게 될 것이다. 뒤에서 배우게 될 텐데, 변수의 값을 초기화하지 않으면 쓰레깃값이라고 한다. 운이 좋아서 종단 문자 자리에 0이 있을 수도 있지만, 0과 1이 잔뜩 있다면 그 쓰레깃값을 출력하게 된다.

Q3. strlen은 라이브러리 없어도 사용할 수 있는가?

strlen은 C의 기본 함수이면서 string.h 라이브러에 있는 함수이다. 뭔가 모순이 있는 게, string이라는 자료형은 intchar같은 기본 자료형이 아닌데, strcpystrlen과 같이 string관련 함수는 왜 존재하는 것일까? C는 문자열(string)을 char *이라고 부르기 때문이다.




© 2020. by RIVER

Powered by RIVER