프로그램을 여러 단위로 쪼갤 수 있는 방법인 함수에 대해 다루고, 함수를 사용하기 위한 개념들을 배웁니다.
지금까지는 컴퓨터가 단순히 메모리와 연산장치가 있다고만 생각했었는데요, 사실 연산장치에도 계산하고 있는 값을 임시로 저장하기 위한 메모리가 하나 더 있습니다. 지금까지 우리가 다룬 주소값이 있는 메모리는 흔히 램이라고 부르는 임의 접근 메모리(Random-access memory; RAM)
이고, 연산장치에 있는 계산 중인 값을 저장하기 위한 메모리는 레지스터(register)
입니다.
덧셈, 뺄셈 같은 산술 연산이든, 논리곱, 논리합 같은 논리 연산이든, 연산장치가 값을 계산하기 위해선 피연산자들이 반드시 레지스터에 들어있어야 합니다. 연산장치가 RAM 안의 공간에 대해서 할 수 있는 연산은 불러오기와 저장하기 밖에 없습니다. 다음 예시를 봐주세요.
int main()
{
int a = 2, b = 5;
int c = a + b;
}
이런 코드를 지금까지는
프로그램이 시작했을 때,
1. 적절한 공간을 a와 b라고 이름짓고 2와 5을 집어넣습니다.
2. 적절한 공간을 c라고 이름짓고 a와 b에 들어있는 값의 합을 집어넣습니다.
이렇게 분석했지만, 실제 컴파일 결과는
프로그램이 시작했을 때,
1. 적절한 공간을 a와 b라고 이름짓고 2와 5을 집어넣습니다.
2. 레지스터 ecx에 b의 값을 불러옵니다.
3. 레지스터 eax에 a의 값을 불러옵니다.
4. eax에 ecx와 eax에 들어있는 값의 합을 집어넣습니다.
5. 적절한 공간을 c라고 이름짓고 eax에 들어있는 값을 집어넣습니다.
이렇게 된다는 것입니다.
연산장치 입장에서 레지스터는 RAM보다 가까운 곳에 있기 때문에 더 쉽고 빠르게 접근할 수 있습니다. 그래서 자주 변하는 값은 레지스터에 저장하는 것이 좋습니다. 프로그래머가 C 코드를 통해 변수를 생성하게 되면, 컴파일러는 자체적인 판단에 따라 그 변수를 RAM 또는 레지스터에 고정시킵니다. 그 변수에 주소 연산자를 사용한 경우가 있으면 RAM에 고정시켜야겠지만, 그런 경우가 없으면 레지스터에 고정시켜도 괜찮을 것입니다.
레지스터들은 계산 중인 값을 저장하는 것 말고도 여러가지 목적을 위해 사용됩니다. 그래서 연산장치를 만드는 회사들은 레지스터들을 목적에 맞게 분류하고, 그 분류에 따라 연산장치의 알맞은 곳에 레지스터를 배치해놓습니다. 컴파일러를 만드는 회사들은 이런 분류에 맞추어 변수가 알맞은 레지스터에 고정되도록 합니다. 이 계산 중인 값을 저장하는 레지스터를 범용 레지스터(general register)
라고 부릅니다.
for문을 사용할 때, 다음 예제처럼 변수를 하나 선언한 후 반복식에서 변수의 값을 1 증가시키는 경우가 많습니다.
#include <stdio.h>
int main()
{
int d; scanf("%d", &d);
for (int i = 0; i < d; ++i)
printf("%d ", i);
}
여기서 i
같은 변수를 루프 카운터(loop counter)
라고 부르는데요, 레지스터에 고정되는 예시 중 하나입니다. d
가 800~804를 차지한다고 가정했을 때, 위 코드를 해석해보면,
1. 800번을 d라고 이름짓습니다.
2. 사용자의 입력을 받아 d가 차지하고 있는 공간에 정수를 넣습니다.
3. 레지스터 ebx에 0을 집어넣습니다.
4. 레지스터 ecx에 d의 값을 집어넣습니다.
5. ebx < ecx의 결과가 1이면 7로 이동하고, 아니면 for문을 종료합니다.
6. ebx에 들어있는 값을 출력합니다.
7. ebx에 들어있는 값을 1 증가시킵니다.
8. 5로 이동합니다.
이렇게 해석될 수 있습니다.
인텔과 AMD에서 생산하는 CPU는 x86 CPU라고 불리는데요, eax, ebx, ecx 등은 x86 CPU의 범용 레지스터들입니다.
CPU의 비교 연산은 본질적으로 빼기 연산입니다. x86 CPU에는 eflags라고 하는 레지스터가 있는데요, 이 레지스터는 마지막으로 실행된 연산의 결과에 따라 값이 변합니다. 위 예제에서
ebx < ecx의 결과가 1
이라고 표현한 것은 사실 eflags가 특정한 값을 가질 때를 말합니다.
지금까지 배운 컴퓨터의 구조를 정리해보겠습니다. RAM과 레지스터, 연산장치가 있습니다. 연산장치는 주어진 프로그램에 따라 RAM이나 레지스터에서 값을 넣고 빼고 연산합니다. 근데 그럼 프로그램은 어디에 있을까요? 프로그램도 일종의 정보인만큼 프로그램을 저장할 메모리가 필요해보입니다.
우리가 쓰는 컴퓨터는 프로그램도 RAM에 저장됩니다. 1장에서 컴파일러는 프로그램이 실행되기 전에 코드를 모두 읽고 번역한다고 했었습니다. 컴파일러가 코드를 번역한 결과는 정수의 배열 형태로 된 프로그램입니다. 그 프로그램을 실행하면 RAM에 그 프로그램이 전부 복사되게 되고, 그럼 연산장치가 RAM에 있는 배열 형태로 된 프로그램을 한 원소씩 읽어 실행할 수 있게 됩니다. 위 예제를 예로 들면, 저 프로그램이 { 0x48, 0x8D, 0x54, 0x24, 0x20, 0x48, 0x8D, 0x0D, 0x3F, 0x11, 0x00, 0x00, 0xE8, 0x7A, 0xFF, ... }
라는 배열이 되어 RAM 어딘가에 존재하게 됩니다. 이 정수들은 무작위적인 숫자가 아니라, 별도의 변환과정 없이 연산장치의 회로가 인식할 수 있는 숫자들입니다. 이 숫자들의 규칙을 기계어(machine code)
라고 합니다.
연산장치는 기계어 코드를 RAM에서 읽기 위해, 다음에 읽을 기계어 코드가 어디있는지 알아야 합니다. 연산장치는 다음에 읽을 기계어 코드가 저장된 공간의 주소도 레지스터에 저장하는데, 이 역할을 하는 레지스터를 프로그램 카운터라고 합니다.
프로그램 카운터는 기본적으로 증가하기만 합니다.
int main()
{
int i = 5;
int j = 6;
int k = i + j;
}
이 프로그램이 어떻게 실행되나요? 먼저 int i = 5;
가 실행되고, int j = 6;
이 실행되고, 그리고 int k = i + j;
가 실행됩니다. 이 코드가 기계어로 변환되면, int k = i + j;
에 해당되는 정수들이 int i = 5;
나 int j = 6;
에 해당되는 정수보다 상대적으로 뒤에 있을 것입니다. 그래서 프로그램 카운터를 증가시키는 것이 다음 문장을 실행하는 결과를 냅니다. 위 예제를 사람들이 하는 말에 가깝게 쓰면,
프로그램이 시작했을 때,
1. (적당한 공간을 i라고 이름짓고) i에 5를 넣습니다.
2. (적당한 공간을 j라고 이름짓고) j에 6을 넣습니다.
3. ecx에 j의 값을 불러옵니다.
4. eax에 i의 값을 불러옵니다.
5. eax에 eax와 ecx에 들어있는 값의 합을 집어넣습니다.
6. (적당한 공간을 k라고 이름짓고) k에 eax에 들어있는 값을 집어넣습니다.
프로그램 카운터가 1, 2, 3, 4, 5, 6(에 대응하는 메모리 주소)으로 변화해 간다는 사실을 알 수 있습니다.
특정한 문장에 대응되는 주소를 프로그램 카운터에 대입할 수도 있습니다. 7장에서 다양한 제어 흐름 문장들에 대해 배웠는데요, if문을 예로 들겠습니다.
#include <stdio.h>
int main()
{
int i; scanf("%d", &i);
if (i < 5)
printf("%d", i + 10);
else
printf("%d", i);
}
프로그램이 시작했을 때,
1. (적당한 공간을 i라고 이름짓고) i라는 이름의 공간에 사용자의 입력을 넣습니다.
2. ecx에 i의 값을 집어넣습니다.
3. ecx < 5의 결과가 거짓이라면 7로 이동합니다.
4. ecx에 ecx + 10의 결과를 집어넣습니다.
5. ecx의 값을 출력합니다.
6. 8로 이동합니다.
7. ecx를 출력합니다.
8.
사용자의 입력이 14
라고 가정해보겠습니다. 그럼 프로그램 카운터는 기본적으로 1씩 증가합니다. 그런데, 2번에서 i < 5
가 거짓이기 때문에 프로그램 카운터가 7로 바뀝니다. 그래서 이 경우 프로그램 카운터가 가지고 있는 값은 1, 2, 3, 7, 8(에 대응하는 메모리 주소)로 변화해 간다는 사실을 알 수 있습니다. 반대로 사용자의 입력이 3
이라면 프로그램 카운터 속 숫자는 1, 2, 3, 4, 5, 6, 8로 변화해 간다는 사실을 알 수 있습니다.
for문에 대해서도 동일한 분석이 가능합니다.
#include <stdio.h>
int main()
{
for (int i = 1; i <= 5; ++i)
printf("%d ", i);
}
프로그램이 시작했을 때,
1. ebx에 1을 넣습니다.
2. ebx <= 5의 결과가 거짓이라면 6으로 이동합니다.
3. ebx에 들어있는 값을 출력합니다.
4. ebx의 값을 1 증가시킵니다.
5. 2로 이동합니다.
6.
그럼 이 프로그램의 경우 프로그램 카운터가 1, 2, 3, 4, 5, 2, 3, 4, 5, ..., 3, 4, 5, 2, 6으로 변화해 간다는 사실을 알 수 있습니다.
지금까지 배운 조건문이나 반복문들은 정해진 규칙대로만 프로그램 카운터가 가지고 있는 주소를 바꿀 수 있었습니다. 그런데, 프로그램 카운터가 프로그래머가 지정한 문장을 가리키게 하는 것도 가능합니다. C에선 이것을 goto문(goto statement)
와 레이블(label)
이라는 기능으로 구현합니다. 이 둘은 각각 다음과 같이 사용합니다.
goto <레이블 이름>;
<레이블 이름>: <선언문이 아닌 문장>;
선언문을 제외한 임의의 문장에 레이블로 이름을 달 수 있는 것을 알 수 있습니다.
사실
선언문(declaration)
은문장(statement)
이 아니지만, 선언문을 문장으로 보면 이해하기 더 쉬운 경우가 있기 때문에(int main { ... }
안에는 항상 문장만 들어간다고 생각할 수 있는 등) 둘을 혼용해서 설명했었습니다. 이 경우는 선언문과 문장이 서로 다르게 취급되는 몇 안되는 경우입니다. 앞으로 설명의 편의를 위해 선언과 선언문이라는 용어를 혼용하겠습니다. 둘 다 declaration을 번역한 용어라고 생각해주세요.
예제를 보겠습니다.
#include <stdio.h>
int main()
{
printf("Hello, ");
goto world;
printf("Python ");
world:
printf("world!");
}
Hello, world!
Python
이 출력되지 않았습니다. 왜냐하면 goto world;
에 의해 프로그램 카운터가 가지고 있는 주소가 printf("world!");
를 가리키도록 바뀌었기 때문입니다. 자세히 분석해보겠습니다.
프로그램이 시작했을 때,
1. "Hello, "를 출력합니다.
2. world로 표시된 문장으로 이동합니다.
3. "Python "을 출력합니다.
4(world). "world!"를 출력합니다.
위 예제는 이렇게 표현될 수 있습니다. 프로그램 카운터가 1, 2, 4로 바뀌었다는 사실을 쉽게 알 수 있습니다.
goto문을 통해 앞으로 이동하는 것도 가능합니다.
#include <stdio.h>
int main()
{
begin:
printf("New line\n");
goto begin;
}
New line
New line
New line
New line
New line
New line
New line
...
무한 루프가 발생하는 이유를 찾으실 수 있나요? 조건 없이 계속 앞 문장으로 이동하기 때문에 New line
을 끊임없이 출력하게 됩니다. 자세히 분석해보겠습니다.
프로그램이 시작했을 때,
1(begin). "New line\n"을 출력합니다.
2. begin으로 표시된 문장으로 이동합니다.
프로그램 카운터가 1, 2, 1, 2, 1, 2, ...로 바뀐다는 것을 알 수 있습니다.
모든 반복문은 if문과 goto로 직접 만들 수 있습니다. 예를 들어 while문은 다음과 같이 바꿔 쓸 수 있습니다.
while(<조건식>) <실행문>
->
begin:
if (!(<조건식>)) goto end;
<실행문>
goto begin;
end:;
end:
다음에 세미콜론이 있는 것에 주목해주세요. 레이블 다음엔 반드시 문장이 와야하기 때문에, end:
다음에 아무것도 안 한다면 빈 문장으로 세미콜론 하나를 적어야 합니다.
goto문은 너무 많이 사용하면 코드가 읽기 힘들어지기 때문에, goto문을 사용해야하는가에 대한 논쟁이 많이 있었습니다. 이건 StackOverflow의 한 질문인데, 무려 49개의 답변이 달린 것을 알 수 있습니다. "goto가 정확히 어떤 일을 일으키는지 알고 쓰면 괜찮을 수 있지만, 실수를 일으키기 좋고 대부분의 경우 대체할 수 있는 방법이 있다"라는 의견이 많이 보입니다. 즉, "위험성은 큰데 얻을 수 있는 건 적다"는 겁니다. 특히 이중에는 "goto문이 유일한 해법인 것처럼 보이는 경우는 사실 다른 부분의 디자인도 별로인 경우"라고 주장하는 사람도 있었습니다. 그래서 goto문은 되도록 사용을 지양하는 것이 좋을 것 같습니다.
물론 goto가 좋다고 생각되는 경우도 몇몇 있습니다. 반복문 안에 반복문을 쓴 경우가 그 예입니다. 3행 3열의 표에 숫자를 저장하되, 대각선 위에 있는 칸에는 음수를 저장하지 않는 프로그램을 만들고 싶습니다. 대각선 위에 있는 칸에 음수를 입력하면 프로그램을 강제종료하고자 합니다.
#include <stdio.h>
int main()
{
for (int i = 1; i <= 3; ++i)
{
for (int j = 1; j <= 3; ++j)
{
printf("%d %d\n", i, j);
int input; scanf("%d", &input);
if (input < 0 && i == j)
goto end;
}
}
end:;
}
1 1
: 3
1 2
: -2
1 3
: -3
2 1
: -6
2 2
: -7
원래는 break문을 두 번써야 하는 것을, goto문 한번으로 쉽게 반복문을 종료할 수 있다는 것을 알 수 있습니다. 이 예제를 break문을 활용해서 쓰려고 한다면
#include <stdio.h>
int main()
{
for (int i = 1; i <= 3; ++i)
{
int valid = 1;
for (int j = 1; j <= 3; ++j)
{
printf("%d %d\n", i, j);
int input; scanf("%d", &input);
if (input < 0 && i == j)
{
valid = 0;
break;
}
}
if (!valid)
break;
}
}
이렇게 복잡하게 적어야합니다. if (!valid) break;
가 없으면 2 2
에서 음수를 입력했어도 3 1
로 이동합니다.
C++에선 RAII라는 것 때문에, goto를 쓰고 싶어도 쓰면 컴파일 오류가 발생하는 경우가 많습니다. RAII에 대해선 나중에 다루도록 하겠습니다.
switch문은 C의 조건문 중 하나입니다. 사용 방법은 다음과 같습니다.
switch (<결과가 정수 자료형인 식>) <문장>