Skip to content

Latest commit

 

History

History
537 lines (385 loc) · 20.5 KB

3_array.md

File metadata and controls

537 lines (385 loc) · 20.5 KB

1) 컴파일링

컴파일

저번 C 시간에 컴파일이 무엇인지 배웠다.

  • 컴퓨터는 2진수만을 이해하므로 C언어로 된 코드를 이해시키기 위해서는 0과 1로 번역해야 한다. 즉, 소스코드를 입력으로 받아 기계코드로 출력하는 일종의 프로그램이 필요한데 우리는 이를 컴파일러라고 부른다.
  • 즉 program.c를 실행가능한 program.exe로 만드는 과정
  • 여기서는 clang이라는 걸 사용할 건데, clang은 코드를 컴파일하는 프로그램의 이름이다.
  • clang hello.c를 입력하면 폴더에 a.out이 생긴다. 소스코드가 기계코드로 컴파일되어 a.out에 담긴 것이다.

이러한 컴파일의 전체 과정은 네 단계로 나누어 볼 수 있다고 한다.


컴파일 과정의 네 단계

1. 전처리(Precompile)

첫번째 단계인 전처리는 전처리기에 의해 수행된다.

#로 시작되는 C 소스코드는 실질적인 컴파일이 이루어지기 전에 무언가를 실행하라고 알려준다.

예를 들어 #include는 전처리기에 다른 파일의 내용을 포함시키라고 알려준다.(stdio.h) 프로그램의 소스 코드에 #include와 같은 줄을 포함하면 전처리기는 새로운 파일을 생성하는데 이 파일은 여전히 C 소스코드 형태이며 stdio.h 파일의 내용이 #include 부분에 포함된다.

그리고 #define A 10과 같은 코드가 있다면 A를 10으로 치환하는 역할도 한다.


2. 컴파일(Compile)

전처리기가 전처리한 소스 코드를 생성하고 나면 그 다음 컴파일러라고 불리는 프로그램이 C 코드를 어셈블리어라는 저수준 프로그래밍 언어로 컴파일한다.

(어셈블리는 C보다 연산의 종류가 훨씬 적지만, 여러 연산들이 함께 사용되면 C에서 할 수 있는 모든 것들을 수행할 수 있다. )

C 코드를 어셈블리 코드로 변환시켜줌으로써 컴파일러는 컴퓨터가 이해할 수 있는 언어와 최대한 가까운 프로그램으로 만들어 준다.

컴파일이라는 용어는 소스 코드에서 오브젝트 코드로 변환하는 전체 과정을 통틀어 일컫기도 하지만, 구체적으로 전처리한 소스 코드를 어셈블리 코드로 변환시키는 단계를 말하기도 한다.


3. 어셈블(Assemble)

소스코드가 어셈블리 코드로 변환되면, 어셈블 단계에서는 어셈블리 코드를 오브젝트 코드로 변환시킨다.

컴퓨터의 중앙처리장치가 프로그램을 어떻게 수행해야 하는지 알 수 있는 명령어 형태인 연속된 0과 1들로 바꿔주는 작업이다.

이 변환작업은 어셈블러라는 프로그램이 수행한다. 소스 코드에서 오브젝트 코드로 컴파일 되어야 할 파일이 딱 한 개라면 컴파일 작업은 여기서 끝난다. 그러나 그렇지 않은 경우에는 링크라 불리는 단계가 추가된다.


4. 링크(Link)

만약 프로그램이 여러 라이브러리를 포함해(math.h나 cs50.h는 라이브러리이고 모두 결국은 파일이다) 여러개의 파일로 이루어져 있어 하나의 오브젝트 파일로 합쳐져야 한다면 링크라는 이 단계가 필요하다.

링커는 여러 개의 다른 오브젝트 코드 파일을 실행 가능한 하나의 오브젝트 코드 파일로 합쳐준다.

예를 들어, 컴파일을 하는 동안에 CS50 라이브러리를 링크하면 오브젝트 코드는 GetInt()나 GetString() 같은 함수를 어떻게 실행할 지 알 수 있게 된다.

이 네 단계를 거치면 최종적으로 실행 가능한 파일이 완성된다.


💡생각해보기

만약 컴파일링 과정을 거치지 않기 위해 바로 머신코드로 우리가 원하는 프로그램을 작성하려고 한다면 어떤 문제가 있을까요?

  • 머신코드는 0과 1로 이루어져 있기 때문에 프로그래밍이 거의 불가능할 것이다.
  • 가능하다고 해도 코드를 작성하는데 시간이 오래 걸리고, 난이도가 상승하며 프로그램의 유지 보수가 힘들어질 것이다.

2) 디버깅

  • 버그: 코드에 들어있는 오류
  • 디버깅: 코드에 있는 버그를 식별하고 고치는 과정

! 디버깅은 아주 중요한 과정이고, 다른 언어로 프로그래밍을 할 때에도 반드시 필요한 기술이다 !

CS50 Sandbox에서 오류가 나면 help50 make [파일이름]을 터미널에 입력해보자.(확장자 없이) 그럼 친절하게 어디서 오류가 났는지 알려줄 것이다. (그런데 help50이라는 명령어는 CS50 Sandbox가 아닌 다른 일반적인 환경에서는 사용하지 못한다.)

CS50 IDE에서 소스코드에 중단점을 걸고 debug50 ./파일이름(확장자 없이)를 터미널에 입력해보자. (debug50 역시 CS50 IDE가 아닌 다른 일반적인 환경에서는 사용하지 못한다.) 그러면 중단점을 건 부분이 노란색으로 강조되면서 오른쪽에 새로운 것이 생기는데, 그것이 바로 디버거이다. 현재 변수의 값이 몇인지를 알려주고 프로그램을 한 줄씩 실행하는 스텝오버 버튼도 사용할 수 있다.

아래의 코드에서 int main(void) 부분에 중단점을 걸고 디버깅하면 변수 i가 0부터 1씩 증가하여 9까지 변화하는 것을 눈으로 확인할 수 있다.

#include <stdio.h>

int main(void)
{
    for(int i=0;i<10;i++){
        printf("i is now: %i\n", i);
    }
}

생각해보기

디버깅을 도와주는 프로그램은 어떤 경우에 더 큰 도움이 될까요? 만약 이런 프로그램의 도움 없이 직접 디버깅을 해야 한다면 어떻게 코드를 작성하는 것이 좋을까요?

  • 디버깅을 도와주는 프로그램(이걸 디버거 프로그램이라고 하나보다 번역체라서 이제 이해함)은 반복문과 조건문이 많은 복잡한 코드를 디버깅할 때 큰 도움이 될 것이다.
  • 이러한 프로그램의 도움 없이 직접 디버깅을 해야 한다면 printf문 등을 사용하여 분기점마다 문제가 없는지 확인해야 할 것이다.

3) 코드의 디자인

규모가 큰 프로그램을 작성할 때는 보통 한 사람이 아닌 여러 사람들이 함께 작업을 진행하게 된다. 이 때는 내가 기여한 부분이 프로그램에 오류를 발생시키지 않도록 주의를 기울여야 한다.

또한 코드의 내용 뿐만 아니라 그 형식도 신경써야 하는데, 같은 내용이라 하더라도 어떻게 표현하느냐에 따라 코드를 이해하고 수정하는 속도가 달라질 수 있기 때문이다.

CS50에서는 check50과 style50 프로그램을 사용하면(여기서만 사용 가능) 코드가 심미적으로 잘 작성되어 있는지 등을 검사할 수 있다.


아래의 코드는 심미적으로 잘 작성된 코드이고,

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

아래의 코드는 심미적으로 잘 작성된 코드가 아니다. 이를 구분하는 기준은 CS50의 Style Guide for C에 잘 나와있다. (C 한정)

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

참고 - 고무오리 디버깅

때로는 코드에 포함된 오류를 해결할 때 앞서 소개한 help50, debug50, check50과 같은 프로그램들이 존재하지 않거나, 있다 하더라도 디버깅에 큰 도움이 안 될 수 있다.

이 때는 먼저 한숨 돌리고 직접 곰곰히 생각해보는 수 밖에 없는데 한가지 유명한 방법으로 ‘고무 오리’와 같이 무언가 대상이 되는 물체를 앞에 두고, 내가 작성한 코드를 한 줄 한 줄 말로 설명해주는 과정을 거쳐볼 수 있으며 이를 통해 미처 놓치고 있었던 논리적 오류를 찾아낼 수도 있다.


💡생각해보기

Q. 만약 여러 사람들이 함께 참여하는 프로젝트에서, 각자가 작성하는 코드 스타일 서로 다르다면 어떤 비효율적인 일이 발생할까요?

A. 서로의 코드를 이해하는데 더 많은 시간이 들 것이다.


4) 배열(1)

C에는 아래와 같은 자료형들이 있고, 각각의 자료형은 서로 다른 크기의 메모리를 차지한다.

  • bool: 불리언, 1바이트
  • char: 문자, 1바이트
  • int: 정수, 4바이트
  • float: 실수, 4바이트
  • long: (더 큰) 정수, 8바이트
  • double: (더 큰) 실수, 8바이트
  • string: 문자열, ?바이트

변수 선언과 메모리

아래의 코드를 컴파일하면 H i !가 출력된다. 여기서 세 개의 변수를 선언했기 때문에 메모리에 각각 c1,c2, c3가 저장된다. 이들은 실제 컴퓨터에서는 'H', 'i', '!' -> 아스키코드인 72, 73, 33 -> 기계어인 이진수로 저장된다.

#include <stdio.h>

int main(void)
{
    char c1 = 'H';
    char c2 = 'i';
    char c3 = '!';
    printf("%c %c %c\n", c1, c2, c3);
}

*주의 C언어에서 문자 하나를 감쌀 때는 쌍따옴표가 아닌 홑따옴표로 감싼다.


배열 선언

아래는 코드는 점수의 평균을 구하는 프로그램이다. 하지만 이렇게 변수를 하나하나 선언하면 수정이 용이하지 않다. 그렇다면 어떻게 해야할까?

#include <stdio.h>

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

해결법으로 배열을 선언할 수 있다. C에서 여러 개의 값을 가진 하나의 변수를 만들고 싶을 때 배열을 사용한다. 배열은 값들의 리스트로, 모두 같은 자료형의 값들이 같은 이름의 변수에 저장되어 있다.

위의 코드와 같이 각각이 정수값인 점수 3개를 갖고 싶다면 대괄호를 사용해서 원하는 점수의 개수를 적고 세미콜론을 붙이면 된다. 이는 컴퓨터에게 정수 3개를 위한 메모리를 달라고 하는 것과 같다.

배열을 이용하여 평균을 구하는 법은 아래와 같다.

#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);
}

💡생각해보기

Q. 실생활의 어떤 데이터를 배열로 표현할 수 있을까요? A. 성적, 출석부, 거래처 이름 등


5) 배열(2)

전역 변수

아래는 배열로 성적의 평균을 구했던 코드이다. 여기서 3은 배열의 원소 개수에서도, printf에서도 하드코딩되어있다. 이는 수많은 버그의 원인이 되므로, 수정해보자.

#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);
}

아래의 코드를 보면 함수 바깥에서 변수를 선언하여(전역변수) 코드의 수정이 용이하게 하였다. (보통 전역변수는 코드 상단에 적고, const라고 선언하고, 변수명은 대문자로 적는다)

#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);
}

배열의 동적 선언 및 저장

아래와 같이 배열의 개수와 배열의 원소를 동적으로 입력받을 수 있다.

이 프로그램은 버그가 하나 있는데, Number of scores: 2, Score 1: 100, Score 2: 99(배열길이: 2, Score1 점수: 100점, Score2 점수: 99점)을 입력하면 평균은 99.5가 나와야 하지만 Average: 99.0라고 출력된다.

어느 부분이 잘못된 걸까? 사실 혼자서는 찾아내지 못했다. 강의를 듣고 보니, average 함수의 return sum / length; 부분이 잘못된 거였다. sum과 length를 모두 int형으로 선언했으니, 정수형 / 정수형을 해서 실수가 아니라 정수가 나오는 거였다.

이를 해결하기 위해 sum과 lengh를 int -> float로 형변환하였는데, 각 변수 앞에 (float)를 적으면 됐다.

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

// 유일하게 복붙해야 하는 부분 ㅎㅎ 함수의 존재 알려주기!
float average(int length, int array[]);

int main(void)
{
    int n = get_int("Number of 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는 반환값의 타입. 
// 오른쪽의 인자들 앞의 타입은 입력받는 타입.
float average(int length, int array[])
{
    int sum = 0;
    for (int i=0; i<length; i++)
    {
        sum += array[i];
    }
    return sum / length;
}

💡생각해보기

Q. 점수의 평균을 구하는 예제에서, 동적으로 작성한 코드는 그렇지 않은 코드에 비해 어떤 장단점이 있을까요?

A. 동적으로 작성한 코드는 상황에 따라 값을 알맞게 도출할 수 있다는 장점이 있다.

또한, 동적으로 작성한 코드가 메모리 적으로 더 유리하다고 한다. 약 1만개의 과목 종류가 있고 이 중 몇 과목만을 골라서 평균을 내는 프로그램이 있다고 가정하자. 이때 동적이지 않은 프로그램은 크기 1만인 배열을 미리 만들어둬야 하지만, 동적인 프로그램은 입력받은 크기만큼 메모리를 할당하면 되기 때문이다. (*아직 잘 이해가 가지 않는다)

동적으로 작성한 코드의 단점으로는 코드가 복잡해진다는 단점이 있다.


6) 문자열과 배열

문자열과 배열의 연관성은?

  • 문자열 또한 배열을 다룰 때처럼 각 문자에 접근할 수 있다.
  • 문자열이란 문자들의 배열이다.(사실 문자열이 문자들의 배열이라는 건 조금 잘못된 말이긴 한데 나중에 알려주신다고 하셨음)
  • 문자열이 있는 이유는? 여러개의 문자들을 저장하는 것이 각 변수마다 하나의 문자를 저장하는 것보다 유용하기 때문이다.

C에서의 string

  • C에서 char은 1바이트, int는 4바이트를 차지하는 반면 string은 글자 수만큼 바이트가 필요하다.
  • 그리고 마지막 문자라는 것을 알려주기 위해 널 문자(\0)가 필요하다. 결국 문자열은 글자수+1바이트만큼의 공간이 필요하다.

문자열 출력하기

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]);

// EMMA
// EMMA

names라는 배열에 차례대로 "EMMA", "RODRIGO", "BRIAN", "DAVID"를 넣으면 실제 메모리에는 아래와 같이 저장된다.

캡처


💡생각해보기

Q. 널 종단 문자는 왜 필요할까요? A. 문자열의 끝을 알려주기 위해


7) 문자열의 활용

(1) 입력받은 문자열을 하나씩 출력해보자.

<1> 마지막 문자가 널문자가 아닐 때까지 출력하면 입력받은 문자열의 모든 문자를 출력할 수 있다.

for( ; ; ;)에서 중간에 있는 부분은 조건으로 이 조건을 만족할 때까지 for문을 도는 것을 의미한다.

여기서는 s[i]!='\0'이므로 문자가 널문자가 아닐 때까지 for문을 돈다는 뜻이다.

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

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

<2> 다른 방법으로는 문자열의 길이만큼 for문을 돌며 문자를 출력할 수 있다.

1. strlen()을 사용하면 된다.

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

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

하지만 이 코드는 개선의 여지가 있다. 문자열의 길이는 변하지 않는데 매번 문자를 출력할 때마다 strlen()함수를 사용하여 strlen(s)과 i가 같은지 비교하기 때문이다.

2. 이는 n에 strlen을 한 번 저장해 놓고 그 이후로는 strlen()함수를 호출하지 않는 방법이 있다.

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

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

3. 더 간결하게 쓰려면 n을 for문 안에서 생성할 수도 있다.

for(int i=0, n=strlen(s); i<n; i++)

(2) 입력받은 문자를 모두 대문자로 바꿔서 출력해보자.

<1> 직접 구현하기

아스키문자표에서 소문자와 대문자는 각각 대응되는데, 소문자에서 32를 빼면 대문자가 된다.

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

int main(void)
{
    string s = get_string("Before: ");
    printf("After: ");
    for(int i=0, n=strlen(s); i<n; i++)
    {
        if (s[i]>='a' && s[i]<='z') // 소문자인가
        {
            // convert to uppercase
            printf("%c", s[i]-32);
        }
        else
        {
            printf("%c", s[i]);
        }
    }
    printf("\n");
}

<2> toupper라는 함수 사용하기

직접 구현하는 것이 아니라 ctype.h에서 제공하는 대문자 변환 함수인 toupper 함수를 사용할 수도 있다.

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

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

💡생각해보기

Q. string.h와 ctype.h의 라이브러리에 다른 어떤 함수가 있는지 확인해 보고, 어떤 함수를 어떻게 활용해 볼 수 있을지 생각해봅시다.

*string.h와 ctype.h를 검색해보시면 사람들이 잘 정리한 글을 찾을 수 있을 것입니다. 코딩에서 자기가 검색해서 공부해보는 것도 매우 중요하기 때문에 직접 찾아보도록 하겠습니다.

A. string.h - 문자열 복사 함수(memcpy, strcpy, strncpy 등), 문자열을 합치는 함수(strcat, strncat 등), 문자열 비교 함수(memcmp, strcmp, strcoll, strncmp 등) 등 ctype.h - isalnum(알파벳 또는 숫자인지 판별), isalpha(알파벳인지 판별), iscntrl(제어 문자인지 판별), isdigit(10진수에 해당하는 숫자인지 판별), islower(소문자인지 판별), isupper(대문자인지 판별), isprint(출력이 가능한가 판별), isspace(빈칸인지 판별), isxdigit(16진수인지 판별) 등


8) 명령행 인자

명령행 인자의 예시) -o와 같은 명령행 인자를 추가하면 원하는 파일명으로 변경하여 컴파일할 수 있다.

명령행 인자는 실행하고자 하는 프로그램 뒤에 적는다.


이제까지는 int main(void){}라는 것을 썼었는데, main함수의 인자로 꼭 void만 들어가야 하는 것은 아니다.

int main(int argc, string argv[])와 같이 작성할 수도 있는데, 이는 main 함수가 두 개의 인자를 받는데 하나는 int이고 다른 하나는 string의 배열이라는 뜻이다.

argc: arguments count로 main함수에 전달된 인자의 개수 argv: arguments vector로 가변적인 개수의 문자열


아래의 파일을 컴파일하고 ./argv David로 실행하면 hello, David가 출력되는데 그 이유는 다음과 같다.

  • argc는 main함수에 전달된 인자의 개수를 의미하며 여기서 argc는 2임. 왜냐하면 ./argv(1개), David(2개)이기 때문.

  • argc가 2일 때 hello, argv[1]을 출력하는데, argv[1]은 David이다. 왜 argv[0]이 아니라 argv[1]이냐면 argv[0]을 출력했을 때 ./argv가 나오는데, 명령행 인자에서 ./argv까지 카운트하기 때문.

// argv.c
#include <stdio.h>
#include <cs50.h>

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

💡생각해보기

Q. 명령행 인자는 프로그램의 확장성에 어떤 도움이 될까요? 구체적인 예시를 떠올려보세요.

A.

  • 효율성: 사용자에게 입력을 받는 구문이 사라져 코드가 간결해짐
  • 확장성: 명령행 인자에 따라 하나의 프로그램에서 여러가지 기능 수행 가능