CS50_3.배열

본 포스트는 boostcourse의 '모두를 위한 컴퓨터 과학 (CS50 2019)'를 바탕으로 작성되었습니다.

컴파일링

#include <stdio.h>

int main(void)
{
	printf("hello, world\n");
}

'clang hello.c'로 위 코드를 컴파일을 하면 a.out이라는 이름의 실행가능한 파일을 생성한다.
만약 다른 이름(hello)으로 컴파일을 하고 싶다면 아래와 같은 명령행 인자(Command Line Argument)를 추가해줘야 한다.

명령행 인자란 프롬프트에서 명령을 실행할 때 해당 명령 뒤에 나열하여 전달하는 인자를 의미한다. (C언어에서는 명령행 인자를 main함수의 매개변수로 전달받을 수 있는데 이는 뒤에서 자세히 배운다.)

clang -o hello hello.c

또한 수업을 위해 만들어진 CS50 라이브러리를 사용한 프로그램을 컴파일 할 때 clang에게 CS50 라이브러리를 연결하라는 의미의 명령행 인자를 추가해줘야 한다.

clang -o hello hello.c -lcs50

make라는 명령어를 이용하면 컴파일 과정을 자동으로 처리할 수 있는데,이 컴파일 과정은 아래 네 단계를 거친다.

  • 전처리(Precompile)
    : 전처리기에 의해 수행된다. 컴파일 전 준비단계라고 할 수 있다. #으로 시작되는 C 소스 코드(전처리기 구문)가 알려주는대로 필요한 헤더파일을 불러오고, 기호상수를 정의한다.
    e.g.
#include <cs50.h>
#define PI 3.141592
  • 컴파일링(compiling)
    : (소스 코드에서 오브젝트 코드(머신코드)로 바꾸는 전체 과정을 통틀어
    컴파일이라고 하기도 한다.) 컴파일 단계에서는 컴파일러가 소스 코드를 어셈블리어라는 저수준 프로그래밍 언어로 변환시킨다. C 코드가 컴퓨터가 이해할 수 있는 언어와 가까워지는 것이다.

  • 어셈블(Assemble)
    : 어셈블러가 어셈블리어를 오브젝트 코드로 변환시켜주는 단계이다. 컴퓨터의 중앙처리장치가 알아들을 수 있도록 연속된 0과 1로 이루어진 명령어 형태로 바꾸는 것이다. (컴파일 할 파일이 한 개라면 여기서 끝이 나지만, 그렇지 않다면 링크라는 단계가 추가된다.)

  • 링크(Link)
    : 프로그램이 여러 개의 파일(cs50.h 등의 라이브러리 포함)로 이루어져 있다면 모든 0과 1들을 하나의 큰 오브젝트 파일로 합쳐준다.

위 네 단계를 거쳐 최종적으로 실행 가능한 파일을 만들 수 있기 때문에,
우리는 복잡한 머신코드를 배울 필요없이 프로그래밍 언어로 원하는 프로그램을 작성할 수 있는 것이다.


디버깅

열심히 작성한 코드가 문제없이 컴파일된다면 좋겠지만, 보통 디버깅이라는 과정을 거쳐야 한다.

디버깅(debugging)은 코드에 있는 버그를 알아내고 고치는 과정이다.
여기서 버그(bug)란 의도하지 않은 실수로 인해 코드 내 들어있는 오류를 말한다.

디버깅에는 다양한 방법이 있다.

먼저, 디버거의 도움을 받을 수 있다. CS50에서 제공하는 help50이라는 디버거를 이용하면, 컴파일 과정 중 어디서 문법적으로 오류가 났는지 친절하게 알려준다.

#include <stdio.h>

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

프로그램이 에러가 나지는 않지만, 의도와 다른 결과가 나올 때는 디버거의 도움도 소용없을 것이다. 위 코드는 #을 10개 출력하기 위해 작성하였지만, 11개가 출력된다. 원인을 찾기 위해 변수가 출력되도록 하여 디버깅을 할 수 있다.


int main(void)
{
	for(int i = 0; i <= 10; i++)
    {
        //i를 같이 출력해보자
    	printf("i is now %i", i);
        printf("#\n");
     }
}

출력 결과 i가 0에서 시작하기 때문에 조건을 수정해야함을 알 수 있다. 이는 논리적 오류의 대표적인 예시로, i<10으로 조건을 수정해보면 의도대로 프로그램이 실행될 것이다.


이처럼 코드를 추가하는 방법 외에도 오류를 시각적으로 확인할 수 있는 방법도 있다. CS50 IDE에서 제공하는 debug50와 같은 프로그램을 이용하는 것이다.

이 디버거는 중지점(프로그램이 멈추는 특정 지점)을 정한 뒤, 그 다음부터는 코드를 한 번에 한 줄씩 실행할 수 있도록 도와준다. 점차 늘어나는 i의 값과 그 i의 값에 해당하는 결과를 보며 i<10으로 수정해야 한다는 것을 알아내게 되는 것이다.

만약 디버거를 이용해도, 어떤 방법을 사용해도 오류가 해결되지 않는다면
최후의 해결책은 그저 한숨 돌리는 것이다. 키보드에서 손을 떼고 생각 모드를 전환한 뒤 다시 돌아와 곰곰히 생각해보면 문제가 해결되는 경우가 많다.

고무오리 디버깅(Rubber Duck Debugging)이라는 유명한 방법도 사용할 수 있다. 고무오리와 같은 어떠한 대상을 두고, 내가 작성한 코드를 한 줄 한 줄 되짚어보며 설명해주는 것이다. 이를 통해 몰랐던 오류를 스스로 깨달을 수도 있다.


코드의 디자인

CS50에서는 코드의 내용이 정확한지 자동으로 검사해주는 check50와
코드가 심미적으로 잘 적성되어 있는지 검사하는 style50을 제공한다.

보통 프로그램은 한 사람이 아닌 많은 사람들의 협업을 통해 작업이 진행되기 때문에, 코드의 정확성과 형식은 매우 중요하다.

그렇기 때문에, 코드가 정확한지 작동 여부를 계속해서 테스트해야하며
사내에서 약속한 혹은 컴퓨터 언어 각각이 제공하는 스타일 가이드를 충실히 따라야 한다.


배열(1)

C 프로그램에는 여러 자료형이 있고, 각각의 자료형은 서로 다른 크기의
저장 공간을 차지한다.

bool: 불리언, 1바이트
char: 문자, 1바이트
int: 정수, 4바이트
float: 실수, 4바이트
long: (더 큰) 정수, 8바이트
double: (더 큰) 실수, 8바이트
string: 문자열, ?바이트
(string은 글자 수에 따라 필요한 메모리가 달라질 것이다.)

소프트웨어 구동 시 정보가 저장되는 곳을 RAM이라 하는데, 이 칩을 우리는 여러 바이트들의 묶음이라고 생각할 수 있다. 예를 들어 char타입의 변수를 하나 생성하고, 값을 입력한다면 RAM의 수많은 바이트 중 한 칸에 그 변수의 값이 저장되는 것이다.

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

int main(void)
{
	int score1 = 72;
    int score1 = 73;
    int score1 = 33;
    
    printf("Average: %i\n",(score1 + score2 + score3)/3);
}

세 점수의 평균을 출력하는 프로그램이다. 만약 점수의 개수를 추가해야 하는 상황이 발생하면 수정해야 할 부분이 많아 보인다.

이렇게 많은 데이터를 저장하고 처리할 때 유용하게 사용할 수 있는 것이 배열이다.

배열은 기초적인 자료구조로 같은 자료형의 데이터를 메모리상에 연이어 저장하고 이를 하나의 변수로 관리하기 위해 사용한다.

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

int main(void)
{
	int scores[3];
	scores[0] = 72;
    scores[1] = 73;
    scores[2] = 33;
    
    printf("Average: %i\n", (scores[0] + scores[1] + scores[2])/3);
}

위 프로그램을 배열을 이용하여 이렇게 작성할 수 있다.

int scores[3];

차례대로 배열 속 변수의 자료형, 배열의 이름, 배열의 길이를 의미한다. int 자료형을 가지는 크기 3인 scores라는 이름의 배열을 선언한다는 것이다.

메모리 주소가 0부터 시작하기 때문에 배열의 인덱스는 0부터 시작한다. 고로 scores의 인덱스는 각각 0, 1, 2가 된다.

위 코드는 배열을 이용하기는 했지만, 여전히 점수의 개수를 바꿀 때 수정해야하는 부분이 많다. 이를 해결하기 위해 배열을 동적으로 선언하고 저장할 수 있다.


배열(2)

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

const int N = 3;

int main(void)
{
    int scores[N];
    scores[0] = 72;
    scores[1] = 73;
    scores[2] = 33;

    printf("Average: %i\n", (scores[0] + scores[1] + scores[2]) / N);
}

scores 배열의 크기를 정해주는 N이라는 변수를 새로 선언하였다.

const int N = 3;

이처럼 함수의 외부에서 선언하여 모든 함수에서 접근할 수 있는 변수를
전역 변수라고 한다. const는 상수(변하지 않는 고정된 값)이라는 의미로, 관례적으로 상수의 이름은 대문자를 사용한다.

점수의 개수를 전역 변수로 선언하였기 때문에 개수를 수정할 때 값을 수정하기 조금 편리해졌지만 여전히 개선해야할 점이 많다.

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

float average(int length, int array[]);

int main(void)
{
    int n = get_int("Scores:  ");

    int scores[n];
    for (int i = 0; i < n; i++)
    {
        scores[i] = get_int("Score %i: ", i + 1);
    }

    printf("Average: %.1f\n", average(n, scores));
}

float average(int length, int array[])
{
    int sum = 0;
    for (int i = 0; i < length; i++)
    {
        sum += array[i];
    }
    return (float) sum / (float) length;
}

위와 같은 방법으로 동적으로 점수의 평균값을 구하는 프로그램을 작성할 수 있다.

 int n = get_int("Scores:  ");

 int scores[n];
 for (int i = 0; i < n; i++)
 {
     scores[i] = get_int("Score %i: ", i + 1);
 }

 printf("Average: %.1f\n", average(n, scores));

배열의 크기를 입력 받고, 그 크기만큼 루프를 돌면서 각 인덱스의 값을
입력받아 저장한다. 그리고 average라는 함수를 선언하여 평균을 구한다.

float average(int length, int array[])
{
    int sum = 0;
    for (int i = 0; i < length; i++)
    {
        sum += array[i];
    }
    return (float) sum / (float) length;
}

average함수는 배열의 길이와 배열을 입력으로 받고 float(실수)을 반환한다.

배열의 길이만큼 루프를 돌면서 값의 합을 구하고, 합을 길이로 나누어 최종적으로 평균값을 반환한다.


문자열과 배열

CS50에서 사용하는 문자열(string)자료형은 사실 문자(char)자료형의 데이터들의 배열이다.

names라는 문자열 형식의 배열에 네 개의 이름을 저장해보자.

string names[4];

names[0] = "EMMA";
names[1] = "RODRIGO";
names[2] = "BRIAN";
names[3] = "DAVID";

printf("%s\n", names[0]);
printf("%c%c%c%c\n", names[0][0], names[0][1], names[0][2], names[0][3]);

첫 번째 printf에서는 첫 번째 인덱스의 값인 "EMMA"가 출력될 것이다.

두 번째 printf에서는 형식 지정자가 %c로 설정되어있는데, 문자열이 아닌 문자를 출력하는 것이다. 여기서 2차원 배열이 생긴다.

대괄호 두 세트를 사용하여 첫 번째는 names의 배열의 인덱스(어떤 배열의 특정 위치)를 나타내고, 두 번째 대괄호는 이름을 문자들의 배열로 보고 그 중 몇 번째 문자인지를 나타낸다.

여기서 추가로 알아야할 것은 각 문자의 끝에 널 종단 문자(\0)가 존재한다는 것이다.

그렇기 때문에 names[0]은 메모리상에서 4byte가 아닌, 'E', 'M', 'M', 'A', '\0'의
5byte 영역을 갖는 글자 배열이 된다.

널 종단 문자는 문자열의 길이가 1byte 증가한다는 단점이 있지만 문자열이 끝났다는 것을 알려주는 중요한 역할을 한다!


문자열의 활용

문자열을 활용하여 프로그램을 만들어보자.

사용자로부터 문자열을 입력받아 출력하는 프로그램은 어떻게 만들어야할까.
for루프를 이용하여 문자열의 인덱스를 하나씩 증가시켜가면서 해당 문자를 출력하면 될 것이다.

해당하는 인덱스의 문자가 \0(널 종단 문자)와 일치하는지 검사하여 문자열의 끝을 알아낼 수도 있을 것이다. 하지만 string.h에 포함된 문자열의 길이를 알려주는 strleng()함수를 사용하는 것이 더욱 효율적이다.

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

int main(void)
{
    string s = get_string("Input: ");
    printf("Output:\n");
    for (int i = 0, n = strlen(s); i < n; i++)
    {
        printf("%c\n", s[i]);
    }
}

n이라는 변수에 문자열의 길이를 저장하고, 그 길이만큼 for루프를 순환한다. 일일이 널 종단 문자와 같은지 검사하는 것보다 효과적이다.

사용자로부터 입력받은 문자를 대문자로 변환하여 출력하는 프로그램으로 바꾸어보자.

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

int main(void)
{
    string s = get_string("Input: ");
    printf("Ouput:  ");
    for (int i = 0, n = strlen(s); i < n; i++)
    {
        if (s[i] >= 'a' && s[i] <= 'z')
        {
            printf("%c", s[i] - 32);
        }
        else
        {
            printf("%c", s[i]);
        }
    }
    printf("\n");
}

사용자로부터 입력받은 문자가 소문자인지(a보다 크고 z보다 작은지) 먼저 검사해서 소문자가 아니라면 그대로 출력한다.

만약 소문자라면 ASCII 값이 각 알파벳의 소문자와 대문자가 32씩 차이난다는 것을 활용해서 32를 해당 문자 값에서 뺀 후 문자로 출력하도록 만든다.

ctype 라이브러리에 toupper()이라는 함수는 동일한 작업을 수행하는데, 이렇게 이미 만들어져있는 도우미 함수를 활용하여 더욱 간단하게 문제를 해결할 수도 있다.


명령행 인자

여태 의미를 몰라 매개변수 없이 main()함수를 사용하였다. void 대신 argc, argv를 넣어 인자값을 전달해보자.

함수를 호출할 때 전달하는 값인 '인자'를 argument라고 하며 argc는 argument vector(인자 개수), argv는 argument vector(인자들의 배열)을 의미한다.

따라서 argc는 main함수가 받게 될 입력의 개수를 저장할 변수이고, argv[]는 그 입력이 포함되어 있는 배열이다.

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

int main(int argc, string argv[])
{
    if (argc == 2)
    {
        printf("hello, %s\n", argv[1]);
    }
    else
    {
        printf("hello, world\n");
    }
}

argv[0]에는 처음에 입력하는 프로그램의 이름으로 저장된다. 그렇기 때문에 그 다음에 입력하는 첫 번째 인자는 argv[0]이 아닌 argv[1]에 저장된다.

int main(int argc, string argv[])

이제 () 안에 입력값의 의미를 알게 되었다.
그렇다면 반환값은 왜 int일까.

C의 main 함수는 기본적으로 반환값을 가진다. 프로그램이 정상적으로 종료된다면 main은 0을 반환하는데, 0은 문제 없음을 의미한다. (0이 거짓인 거와는 별개이다.)

좋은 웹페이지 즐겨찾기