[CS] TIL 5. 사용자 정의 함수, 중첩 루프, 하드웨어의 한계


  1. 사용자 정의 함수와 중첩 루프를 작성할 수 있어야 한다.
  2. 메모리 용량이 프로그램의 구동에 미치는 영향을 설명할 수 있어야 한다.


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


1. 사용자 정의 함수

#include <stdio.h>

int main(void)
{
    printf("cough\n");
    printf("cough\n");
    printf("cough\n");
}

“cough”라는 문자열을 세 번 말하게 하기 위해서는 위 코드처럼 작성하면 된다. 단순히 printf()를 세 번 복사하면 편하지만, 동일한 작업을 반복하는 것이기 때문에 코드가 필요 이상으로 더러워지고 길어진다.

#include <stdio.h>

int main(void)
{
    for (int i = 0; i < 3; i++)
    {
        printf("cough\n")
    }
}

이렇게 반복되는 것을 해결할 때는 어제 배운 루프를 사용하면 된다. whilefor문 중에서 for문을 말이다. while문을 사용해도 되만, for문을 사용하면 한 줄로 해결할 수 있기 때문이다.

#include <stdio.h>

void cough(void)
{
    printf("cough\n")
}

int main(void)
{
    for (int i = 0; i < 3; i++)
    {
        cough();
    }
}

여전히 짧기 때문에 문제 될 것은 없지만, 이것이 길어진다면, 자신만의 함수로 지정해주면 된다. #include <stdio.h> 밑에 줄을 보면 int main()으로 시작하는 것이 아니라, void cough()로 시작한다. 사용자 지정함수를 만들 때는 int가 아니라 void를 입력하고 뒤에 원하는 함수명을 적어준다. 그리고 ()안에 void를 작성한다. 그리고선 이 함수를 실행했을 때 행해질 행동을 {}에 적어주면 된다. 우리는 기침을 해야 하니 printf()를 사용하여 “cough”라는 문자열을 리턴하게 해줄 것이다. 이렇게 만들어진 사용자 지정함수는 사용하고자 하는 부분에 적어주면 된다. 위 코드에서는 int main(void)for에서 사용해주었다. 그럼 이제 cough()가 어떻게 구현된 것인지는 전혀 알 필요가 없다. “i를 0부터 3까지 세어가면서 cough()를 세 번 반복한다.” 이 사실에만 집중하면 되는 것이다. 이것이 추상화란 개념이다. 다시 한번 말하면 cough()라는 printf()로 구현된 것은 몰라도 cough()가 있다는 것만 알면 된다.

#include <stdio.h>

int main(void)
{
    for (int i = 0; i < 3; i++)
    {
        cough();
    }
}

void cough(void)
{
    printf("cough\n")
}

이 코드엔 여전히 개선해야 할 부분이 존재한다. 이 프로그램에서 가장 중요한 main()이 함수를 만들수록 아래로 내려가기 때문이다. 이 파일을 열었을 때 가장 중요한 부분이 먼저 보여야 하는데 그렇지 않다는 게 문제라는 것이다. 이것을 해결하고자 위에 코드처럼 cough()함수를 main() 아래로 내려버리면 컴파일링시 오류가 난다.

title

implicit declaration of function 'cough' is invalid in C99 : `cough9()`의 묵시적 선언은 C'99에서는 유효하지 않습니다. (C’99는 1999년에 나온 C버전을 의미힌다.) 이 오류의 뜻은 무엇일까. C는 우리가 시키는 대로만 할뿐더러 반드시 위에서 아래로, 왼쪽에서 오른쪽으로 실행한다. 즉, 위 코드를 살펴보면 처음에 stdio.h를 추가했고, main()의 시작 부분을 입력했다. main()이 실행되면 for문이 실행되어 cough()라는 함수를 실행하는 구조이다. 그런데 이 cough()main() 아래에서 실행되었다. C는 cough()main() 아래 있는 것을 고려하지 않는다. 지금까지 배운 것을 바탕으로 이 문제를 해결하기 위해서는 처음에 했던 것처럼 사용자 지정함수를 맨 위로 올려야 한다. 하지만, 지금부터 다른 방법(main()을 가장 위로 올릴 방법)으로 이 문제를 해결할 것이다.

#include <stdio.h>

void cough(void);

int main(void)
{
    for (int i = 0; i < 3; i++)
    {
        cough();
    }
}

void cough(void)
{
    printf("cough\n");
}

void cough(void)를 세미콜론과 함께 위로 올리는 것이다. 마치 이전에 cough()를 봤던 것처럼 C를 속이는 방법이다. 위 코드를 컴파일링하면, cough()를 전부 본 적은 없어도 이름은 본 적이 있으니 main 함수에 나올 때까지 코드를 계속 읽는다.

#include <stdio.h>

void cough(int n);

int main(void)
{
    cough(3);
}

void cough(int n)
{
    for (int i = 0; i < n; i++)
    {
        printf("cough\n");
    }
}

이제 cough()를 다재다능하게 만들어서 원하는 횟수만큼 “cough”라는 문자열을 출력하게 해보자. 사용자 지정함수를 만들 때 ()에 들어가는 void는 입력을 받지 않는다는 뜻이다. void 자리에 입력받고자 하는 형식지정자와 변수명 하나를 입력해주면 된다. 위 코드를 보면 int n을 입력했다. 뒤에 ;(세미콜론)을 붙이지는 않지만, 이것은 변수를 선언한 것이다. 이것을 매개변수라 한다. 이제 이 함수 안에 for문을 작성해주면 되는 것이다. 이때 중요한 것은 i은 우리가 원하는 횟수(n)만큼 늘어야 한다. (이렇게 cough()를 매개 변수화 시키면, main() 위에 있는 부분도 동일하게 바꿔야 한다. main() 위에 있는 void cough(int n)을 프로토타입이라고 한다.) 다시 main()으로 돌아가 보면 {}안에 cough(3)만 입력된 것을 볼 수 있다.

이것이 잘 디자인 프로그램이다. 딱 한줄이지만 필요한 내용을 잘 설명하고 있다. 해당 프로그램을 열면 cough(기침)을 3번 한다는 것을 알 수 있을 것이다. 이것은 비단 main(){} 내용이 한 줄이기 때문만이 아니라, cough()라는 함수명이 어떤 것을 실행하는지 잘 보여주는 이름이기 때문이기도 하다. 함수명, 변수명도 매우 중요하다는 뜻이다. 세부 정보는그 파일 아래로 내려가면 확인할 수 있다. main() 아래에 적인 사용자 정의 함수를 세부 정의라고 부른다. 누군가는 cough()를 어떻게 정의했는지 궁금해할 수 있지만 적어도 우리들은 정수를 어떻게 받아오는지 printf()를 어떻게 사용하는지 알 필요가 없다. 우리가 어떻게 정의됐는지 모르고 사용하는 printf(), int라는 형식지정자처럼 누군가가 구현해 준 기능을 그대로 활용해서 흥미롭고 잘 디자인된 프로그램을 만들면 되는 것이다.


같은 개념을 이용해 좀 더 이해하기 쉬운 예제를 확인해보자.

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

int get_positive_int(void);

int main(void)
{
    int i = get_positive_int();
    printf("%i\n", i);
}

int get_positive_int(void)
{
    int n;
    do
    {
        n = get_int("Positive Integer: ");
    }
    while (n < 1);
    return n;
}

cs50라이브러리와 stdio 라이브러리에는 get_positive_int()는 없다. 하지만 함수명만 읽어보아도 양의 정수를 받아오는 프로그램이라는 것을 금방 알아차릴 수 있을 것이다. main() 아래의 세부 정보를 살펴보면 이 함수의 논리를 차근차근 확인할 수 있다.

int get_positive_int(void)

매개변수 자리에 void를 사용했기 때문에 그 어떤 값도 입력받지 않아도 된다. mian()int i = get_positive_int();를 보면 아무것도 입력받지 않은 get_positive_int()i라는 변수에 할당해주었다. 하지만 CS50에 있는 get_int()get_string()처럼 어떤 값을 받아와서 변수에 저장하는 것처럼 이 함수가 무엇인가를 반환하게 하고 싶다. 그래서 void i = get_positive_int();가 아니라 int i = get_positive_int();인 것이다. 함수의 시작점이 void가 아니라 int라는 것이다. 시작점에 있는 int(파란색)는 출력의 종류를 의미한다. int get_positive_int(void) 괄호 안의 빨간색 단어(void)입력의 종류를 뜻합니다. 만약 입출력이 없다면 void를 적어주시면 된다.

int n;

int get_positive_int(void)의 정의를 읽어보면 int n이라는 것이 보인다. 이것은 컴퓨터에게 n이라고 하는 변수를 달라는 일종의 힌트이다. 그 안에 어떤 값을 저장할지 모르기 떄문에 그냥 int n;만 적은 것이다. 아직은 아무것도 할당할 필요가 없다. 사용자에게 직접 받아야하기 때문이다. 그럼 n은 쓰레기 값(Garbage Value)이라고 부르는 값을 가지게 된다.

do
{
    n = get_int("Positive Integer: ");
}
while (n < 1);
return n;
}

그 다음에는 처음보는 루프가 있다. C에서는 이것을 do-while의 루프라고 부른다. 블리언이 참일때 do{}를 실행하라는 것이다. 만약 n이 1보다 작으면 계속해서 질문을 할 것이다.

do-while루프가 참 좋은 것은 while의 조건이 참이건 아니건, do{}부분은 적어도 한번 실행한다는 것이다. 이후에 사용자의 협조에 따라 do{}를 다시 실행할지 아닐지를 결정한다. 이것은 그냥 프로그래밍의 다양한 방식 중 하나에 불과하다 어제 배운 while루프나 for루프에서도 똑같이 구현할 수 있다.



2. 중첩 루프

title

마리오라는 게임을 보면 공중에 있는 벽돌들을 마리오나 루이지가 머리로 박으면 동전이나 다른 것이 나온다. 이는 아주 좋은 생각인데 프로그래밍적인 방법을 활용하도록 하기 때문이다.

#include <stdio.h>

int main(void)
{
    printf("????\n")
}

예를 들어 간단한 물음표 4개를 출력하는 프로그램을 만들면 어떨까? 실제로 마리오 게임을 구성하는 코드의 어딘가에는 위와 같이 콘솔 게임으로 하여금 물음표 4개를 출력하는 코드가 있을 것이다. 위 코드처럼 물음표를 연달아 출력할 수도 있지만, 내가 어제 배운 for문을 이용해 나타낼 수도 있다.

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

int main(void)
{
    int n;
    do
    {
        n = get_int("Widh: ")
    }
    while(n < 1);
    for(int i = 0; i< n; i ++)
    {
        printf("?")
    }
    printf('\n')
}

위 코드는 while문과 for문을 같이 사용한 코드이다. main()에서는 n이라는 정수를 입력받는데 do-while문을 통해 n이 1보다 작이면 이를 반복한다. do{} 부분은 출력하고 싶은 블록의 폭이 얼마인지를 묻는 부분이다. get_int()n의 값을 얻으면 for문을 통해 물음표를 n 번을 출력한다. 그리고 마지막에는 \n으로 새로운 줄을 출력하게 된다. 1을 초과하는 양의 정수를 얻으면 물음표를 출력하겠지만, 1 이하의 값을 주면 계속 같은 질문을 하게 된다.

title

다음으로 볼 마리오 게임은 벽돌로 가득한 지하 공간이다. 이것은 벽돌 한 줄이 아니라, 2차원 인 것이다. 이것의 흥미로운 점은 블록들을 한 줄이 아니라 여러 줄로 출력하여 2차원 구조로 만들어야 하기 때문이다. 이것 또한 루프를 활용하면 된다.

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

int main(void)
{
    int n;

    do
    {
        n = get_int("Size: ");
    }
    while (n < 1);

    for (int i = 0; i < n; i++)
    {
        for (int j = 0; j < n; j++)
        {
            printf("#");
        }
        printf("\n");
    }
}

이번에는 길이가 아니라, 사이즈를 물었다. 마리오 게임의 벽돌처럼 네모난 구조를 만들고 싶기 때문이다. 역시나 1을 초과하는 양의 정수 값을 얻을 때까지 계속해서 크기를 묻는다.

for (int i = 0; i < n; i++)
{
    for (int j = 0; j < n; j++)
    {
        printf("#");
    }
    printf("\n");
}

코드 중간에 있는 부분을 살펴보자, i를 n번 반복하면서 동시에 j도 n번 반복한다. 이것은 열과 행을 출력하는 것과 같다. 마치 오래된 타자기처럼 왼쪽에서 오른쪽으로, 위에서 아래로 움직이며 여러 블록을 여러 줄에 출력하는 것이다.

n개의 열에 대해 각 열마다 i 가 0, 1, 2,…, n과 같이 증가한다. 동시에 각 열 내부에는 또 다른 루프가 존재한다. 루프가 중첩되어 각 문자를 왼쪽에서 오른쪽으로 출력하는 것이다.



3. 하드웨어의 한계

컴퓨터의 메모리는 유한하다. 컴퓨터 혹은 맥북에는 메모리 혹은 RAM이라는 물리적 저장장치를 포함하고 있는데, 이곳에서 모든 프로젝트가 작성되고 실행된다. 그리고 모든 파일이 열려있는 동안 저장되는 곳이기도 하다. 컴퓨터가 여러 일을 한 번에 할 때 기억하기 위해 사용하는 것이다. 하지만 컴퓨터가 할 수 있는 일에는 근본적인 한계가 있다. 처음 말했던 것처럼 RAM의 성능 및 크기는 유한하다는 것이다. 1GB는 1억 바이트이거나 4GB는 4바이트일 수도 있다. 당연히 무한대는 셀 수도 없다. 저장공간, 저장 가능한 숫자, 연산 등에도 한계가 있다.

3.1 부동 소수점 부정확성

아래는 실수 x, y를 인자로 받아 x 나누기 y를 하는 프로그램이 이다.

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

int main(void)
{
    // 사용자에게 x 값 받기
    float x = get_float("x: ");

    // 사용자에게 y 값 받기
    float y = get_float("y: ");

    // 나눗셈 후 출력
    printf("x / y = %.50f\n", x / y);
}

get_float()로 사용자에게 값을 받고, 또 하나 만들어서 이번에는 y라는 변수에 똑같이 받아온다. float은 소수점이 있는 숫자, 즉 실수를 의미한다. 나눈 결과를 소수점 50자리(%.50f)까지 출력하기로 하고, x에 1을, y에 10을 입력하면 아래와 같은 결과가 나온다.

x: 1
y: 10
x / y = 0.10000000149011611938476562500000000000000000000000

정확한 결과는 0.1이 되어야 하지만, 컴퓨터에 따르면 1/10의 결과 값은 0.1000… 에 해당한다. 이렇게 되는 이유는 무엇일까? 컴퓨터의저장 용량에는 한계가 있어 특정 지점 뒤에는 무슨 일이 일어날 수 있는지알 수 없기 때문이다. 즉, float을 포함한 컴퓨터에서는 저장 가능한 비트 수가 유한하기 때문에 다소 부정확한 결과를 내게 된다.(float은 32비트를 double은 그 두배인 64비트를 사용한다. 뭐가 되었건, 저장가능한 비트 수는 유한하다.)

3.2 정수 오버플로우

#include <stdio.h>
#include <unistd.h>

int main(void)
{
    for (int i = 1; ; i *= 2)
    {
        printf("%i\n", i);
        sleep(1);
    }
}

비슷한 오류로, i를 0부터 세기 시작해서 i에 2를 곱해준다. *= 2는 ×2를 의미한다. 그리고 이것을 영원히 반복한다. for문 두 번째 자리를 비웠기 때문이다. true를 입력해도 영원히 반복하지만 그냥 비워도 같은 의미가 된다. sleep(1)이라는 함수는 이 프로그램을 그냥 실행하면 금방 가득찰 것이 분명해 숫자 사이사이에 1초간 쉬도록 하는 것이다. 이 함수를 이용하기 우해서는 unistd.h라는 라이브러리를 참고해야한다.

...
1073741824
overflow.c:6:25: runtime error: signed integer overflow: 1073741824 * 2 cannot be represented in type 'int'
-2147483648
0
0
...

이것을 실행하다보면 2를 계속 곱하다가 int 타입을 저장할 수 있는 수를 넘을 것이다. 그렇게 되면 결국 0이 된다. 왜 하필 0일가? 계속해서 숫자를 키워나가다보면 언젠가는 앞 자리에 1을 더할 비트조차 없어지기 때문이다. 즉, 더 큰값을 저장할 수 없기 때문이다. (컴퓨터는 2진수를 사용한다는 것을 기억하면 이해가 쉽다. 10진법으로 나타낸 123은 계속해서 1을 더할 수 있다. 그리고 9까지 가게되면 0이 되면서 앞자리에 1을 가져가서 130이 된다. 아무문제가 없지만 만약 숫자를 3자리수만 사용할 수있다면? 999가 되었을 때 1을 더하게 되면 000이 된다. 이것이 비트의 한계에서 출력값이 9이 되는 이유이다. ) 위는 정수 프로그램을 실행시켰을 때의 출력값이다.


이런 오버플로우 문제는 실생활에서도 종종 발견된다.

1999년에 큰 이슈가 되었던 Y2K 문제는 연도를 마지막 두 자리수로 저장했던 관습 때문에 새해가 오면 ‘99’에서 ‘00’으로 정수 오버플로우가 발생하고, 새해가 2000년이 아닌 1900년으로 인식된다는 문제였다. 그리고 세계는 수백만 달러를 투자해서 프로그래머들에게 더 많은 메모리를 활용해서 이를 해결하도록 하였다. 이는 통찰력 부족으로 발생한 아주 현실적이고 값비싼 문제였다.

또한 다른 사례로 비행기 보잉 787에서 구동 후 248일이 지나면 모든 전력을 잃는 문제가 있었다. 강제로 안전 모드로 진입하였기 때문이었는데, 이는 소프트웨어의 변수가 248일이 지난 뒤에 오버플로우가되어 발생하는 것이다. 248일을 1/100초로 계산하면 대략 2의 32제곱이 나온다. 보잉을 설계할때 사용한 변수보다 너무 커졌던 것이다. 이를 해결하기 위해 주기적으로 재가동을 하여 변수를 다시 0으로 리셋했다.

따라서 다루고자 하는 데이터 값의 범위를 유의하며 프로그램을 작성하는 것이 중요하다.


더 공부하길 바라는 것

  1. 프로토타입이란 무엇인가?





© 2020. by RIVER

Powered by RIVER