[CS] TIL 15. 문자열, 문자열 비교, 문자열 복사
- CS50라이브러리에 string이 어떻게 정의되었는지 설명할 수 있다.
- 문자열이 저장되어 있는 방식에 근거해서 문자열을 비교하는 방법에 대해 설명할 수 있다.
- 메모리 할당을 통해 문자열을 복사하지 않고, 단순히 문자열의 주소만 복사했을 때는 어떤 문제가 생길까?
boostcourse의 모두를 위한 컴퓨터 과학 (CS50 2019) 강의를 듣고 정리한 필기입니다. 😀
1. 문자열(string)
1.1 문자열이란?
지금까지 작성한 프로그램 대부분은 사용자에게 어떤 글자를 입력받아 그 값을 사용했다. 그리고 문자열을 저장하기 위해서 CS50 라이브러리에 포함된 string
자료형을 사용하였다.
string s = “EMMA”;
“EMMA”라는 문자열을 변수 s
에 저장하면 컴퓨터 안에서 어떻게 보일까? 메모리 속 어딘가에 5바이트 공간에 저장되어있을 것이다. 왜 “EMMA”는 네 글자인데 5바이트의 공간을 차지할까?
그건 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]
, … 과 같은 하나의 문자가 배열의 한 부분을 나타낸다.
하지만 어제 포인터에서 공부했듯이 바이트는 고유의 조소를 가지고 있다. 예를 들어 E의 위치는 0x123이고, M은 0x124, 또 다른 M은 0x125, A는 0x126, 종단 문자 \0은 0x127이라고 하자. 문자열은 문자 하나하나가 계속 이어지는 형태이기 때문에 메모리에 저장될 때도, 메모리의 주소가 시작부터 종단 문자까지 이어진다.
그렇게 되면 변수 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이 저장된 변수 n
을 p
라는 변수에 저장한 것처럼, 문자는 char *
을 이용해 문자를 가리키는 주소를 저장할 수 있다.
typedef struct
{
string name;
string number;
}
person;
우리는 TIL11에서 typedef
에 대해서 배웠다. typedef
에 대해서 빠르게 훑으면, 새로운 타입을 정의한다는 말로써, 우리는 이것을 이용해 C에는 없지만 우리들의 프로그램에는 존재하는 자료형을 만들 수 있다. 위 코드처럼 이름이나 숫자와 같은 다양한 변수를 하나로 묶어서 person
이라는 자료형을 선언할 수 있었다. typedef
는 사실 이것보다도 훨씬 더 간단하게 사용할 수 있다.
typedef char *string
위 코드에서 typedef
는 새로운 자료형을 선언한다는 의미이고 char *
는 이 값의 형태가 문자의 주소가 될 것이라는 의미이다. 그리고 마지막에 있는 string
은 자료형의 이름을 의미한다. 실제로 CS50 라이브러리의 헤더 파일에 위와 똑같은 코드가 적혀 있다.
즉, 우리가 사용하던 string
이라는 자료형은 char *
과 동일하며, 이것은 일종의 추상화이다. 문자의 나열을 주소 하나로 나타낼 수 있다는 사실을 단순화시킨 것이다.
string 자료형을 이용하여 “EMMA” 출력
#include <cs50.h> #include <stdio.h> int main(void) { string s = "EMMA"; printf("%s\n", s); // EMMA }
char
포인터를 이용하여 “EMMA” 출력#include <stdio.h> int main(void) { char *s = "EMMA"; printf("%s\n", s); // EMMA }
2번 코드의
char *s
에서s
라는 변수는 문자에 대한 포인터가 되고, “EMMA”라는 문자열의 가장 첫 번째 값을 저장한다.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가 나올 것이고, i
와 j
모두 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");
}
}
위는 사용자로부터 문자열을 입력받아 비교하는 것이다. 실행해보면 알겠지만, s
와 t
에 똑같이 EMMA를 입력해도 Different라는 문장이 출력될 것이다. Emma, emma 모두 같은 Different를 출력한다. 왜 다르다고 이야기를 할까? 이유는 각 변수가 저장된 주소가 다르기 때문이다.
엄밀히 말하면 위 프로그램이 비교한 것은 두 변수의 주소이다.(string
는 char *
로 바꿀 수 있다는 것을 기억하자. 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를 출력한다. 분면 toupper
은 t
에만 해줬음에도 s
까지 대문자로 바뀌는 이유는 무엇일까?
string t = s;
를 통해 t
라는 변수에 s
를 복사했는데, s
는 사용자가 입력한 문자열이 아니라, 메모리의 주소가 저장되어있다. (string s
와 char *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. 마지막 종단 문자를 복사하지 않으면 어떻게 될까?
▶아무도 모른다. s
와 t
를 출력하고 할 때, 종단 문자가 없다면 그 뒤 내용까지 다 출력하게 될 것이다. 뒤에서 배우게 될 텐데, 변수의 값을 초기화하지 않으면 쓰레깃값이라고 한다. 운이 좋아서 종단 문자 자리에 0이 있을 수도 있지만, 0과 1이 잔뜩 있다면 그 쓰레깃값을 출력하게 된다.
Q3. strlen
은 라이브러리 없어도 사용할 수 있는가?
▶strlen
은 C의 기본 함수이면서 string.h
라이브러에 있는 함수이다. 뭔가 모순이 있는 게, string
이라는 자료형은 int
나 char
같은 기본 자료형이 아닌데, strcpy
나 strlen
과 같이 string
관련 함수는 왜 존재하는 것일까? C는 문자열(string
)을 char *
이라고 부르기 때문이다.