15. Writing Large Programs(2)

하모씨·2021년 11월 16일
0

KNKsummary

목록 보기
17/23

3. Dividing a Program into Files

헤더파일과 소스파일에 대해 우리가 알고있는 것을 사용하여 프로그램을 여러개의 파일로 나누는 간단한 기술을 개발해보자. 함수에도 집중하고 함수와 같은 원리가 적용되는 외부 변수에도 집중해야 한다. 프로그램에 필요한 함수가 무엇인지, 논리적으로 관련된 그룹의 함수들을 어떻게 배치해야할 것인지에 대해 이미 결정이 되어있어서 프로그램이 이미 다 디자인되었다고 가정할 것이다.(프로그램 디자인에 대해선 Chapter 19에서 알아볼 것이다)

각각의 함수의 집합은 분리된 소스파일로 들어갈 것이다(이러한 파일에 foo.c라는 이름을 사용하자). 추가로, 확장자가 .h인 소스파일과 이름이 동일한 헤더파일을 만들 것이다(우리의 경우엔 foo.h). 우리는 foo.c에 정의된 함수들의 prototype을 foo.h에 넣을 것이다.(foo.c에서만 사용하도록 설계된 함수는 foo.h에 선언되어선 안된다. 우리의 예시에선 여기에 해당하는 함수는 read_char이다.) foo.c에 정의된 함수의 호출을 필요로 하는 각각의 소스파일에 foo.h를 포함시킬 것이다. foo.cfoo.h를 포함하는 것으로 컴파일러가 foo.c 내부의 정의와 foo.h의 prototype의 일치를 확인할 수 있도록 할 것이다.
main 함수는 프로그램의 이름과 일치하는 파일에 들어갈 것이다. 만약 우리가 bar라고 알려진 프로그램을 원한다면, main은 파일 bar.c에 들어가는게 맞을 것이다. 다른 파일에서 호출되지 않는 한 main이 있는 파일에 또다른 함수가 있을 수도 있다.

PROGRAM: Text Formatting

우리가 논의했던 기술을 표현하기 위해, justify라는 이름의 작은 text formatting 프로그램에 이 기술을 적용시켜볼 것이다. justify의 샘플이 되는 입력값으로, 우리는 The development of the C programming language(in History of Programming Languages II, edited by T. J. Bergin, Jr., and R. G. Gibson, Jr., Addison-Wesley, Reading, Mass., 1996, pages 671-687)의 문장을 포함한 파일(아주 안타까운 서식을 가졌다)인 quote를 사용할 것이다.

   C       is quirky,     flawed,      and an
enormous      success.         Although accidents of    history
 surely    helped,     it evidently    satisfied    a     need
 
    for  a    system  implementation     language    efficient
    
    enough  to  displace             assembly   language,
       yet sufficiently   abstract   and fluent    to describe
     algorithms  and     interactions    in a    wide     variety
    of   environments.
                       --        Dennis     M.             Ritchie

UNIX나 Windows prompt에서 프로그램을 실행하기 위해서, 우리는 아래의 커맨드를 입력할 것이다.

justify <quote

< 심볼은 operating system에게 justify는 키보드로 입력값을 받아들이는게 아닌, 파일 quote로부터 입력값을 읽도록 알려준다. 이 특징은 UNIX, Windows 등의 operating system에서 지원되는 input redirection이라고 불리는 특징이다. quote 파일이 입력값으로 주어졌을때, justify 프로그램은 아래의 output을 출력한다.

C is quirky,  flawed,  and  an  enormous  success.  Although
accidents of history surely helped, it evidently satisfied a
need for a system implementation language  efficient  enough
to displace assembly language, yet sufficiently abstract and
fluent to describe algorithms and  interactions  in  a  wide
variety of environments. -- Dennis M. Ritche

justify의 output은 화면에 평범하게 나타나지만, output redirection을 사용하여 output을 파일에 저장할 수 있다.

justify <quote >newquote

justify의 output은 파일 newquote에 들어간다.
일반적으로, justify의 output은 input과 동일한데, 차이점은 추가적인 공백과 비어있는 행이 삭제되고 행들이 채워지고 조정된다는 점이다. "행을 채운다"의 의미는 하나의 단어가 더 들어간다면 행이 넘치는 상태까지 단어를 추가한다는 의미이다. "행을 조정한다"의 의미는 각각의 행이 실제로 같은 길이(60문자)가 되도록 단어들 사이에 추가적인 공백을 추가하는 것이다. 조정은 행 내부에서 단어들 사이의 공백이 동일하도록(또는 가능한 비슷하도록) 이루어져야 한다. output의 마지막 행은 조정되지 않을 것이다.
20문자를 넘는 단어는 없다고 가정할 것이다(문장 부호는 인접한 단어의 일부라고 여길 것이다).
당연하게도, 이것은 조금 제한적이긴 하지만 프로그램이 일단 작성되고 디버깅되면 여기에 대한 제한을 쉽게 늘릴 수 있고, 사실상 이 제한을 절대 초과할 수 없다. 만약 프로그램이 긴 단어를 마주쳤다면, 처음 20글자 이후의 모든 문자를 무시할 것이고 하나의 별표(*)로 대체할 것이다.

antidisestabilishmentarianism

위의 단어는 아래의 단어로 출력될 것이다.

antidisestablishment*

이제 우리는 프로그램이 무엇을 해야하는지 이해했고, 어떻게 설계할지 생각해볼 시간이다. 단어를 읽었을 때 한 단어씩 계속 작성하는 것이 아니고, 대신에 행을 채우기에 충분할 때까지 "line buffer"에 단어들을 저장하는 프로그램을 생각해보는 것으로 시작하자. 더 깊이 생각해보면, 프로그램의 핵심은 아래와 같은 루프가 될 것이다.

for (;;) {
    read word;
    if (can't read word)
    {
        write contents of line buffer without justification;
        terminate program;
    }
    
    if (word doesn't fit in line buffer)
    {
        write contents of line buffer with justification;
        clear line buffer;
    }
    add word to line buffer;
}

우리는 단어를 처리하는 함수와 라인 버퍼를 처리하는 함수를 필요로 하기 때문에 프로그램을 3개의 소스파일로 나누고, 하나의 파일(word.c)에는 단어와 관련된 모든 함수를, 또다른 파일(line.c)에는 line buffer와 관련된 모든 함수를 넣자. 세번째 파일(justify.c)은 main 함수를 가질 것이다. 이 파일들에 더해, 우리는 2개의 헤더파일이 필요한데 word.hline.h이다. word.h 파일은 word.c내부의 함수들에 대한 prototype을 가진다. line.hline.c내부의 함수들에 대한 prototype을 가진다.
메인 루프를 조사해보면, 단어와 관련된 함수 중 우리가 필요한 것은 오직 read_word 함수뿐이라는 것을 알 수 있다(함수가 input 파일의 끝에 도달해서 read_word가 단어를 읽을 수 없다면, "빈" 단어를 읽었다고 가장(위장)하는 것으로 메인 루프에 신호를 보낸다). 결과적으로, word.h 파일은 작은 파일이다.
word.h

#ifndef WORD_H
#define WORD_H
/************************************************************
 * read_word: Reads the next word from the input and        *
 *            stores it in word. Makes word empy if no      *
 *            word could be read because of end-of-file.    *
 *            Truncates the word if its length exceeds      *
 *            len.                                          *
 ************************************************************/
void read_word(char *word, int len);

#endif

어떻게 WORD_H macro가 word.h를 두 번 이상 포함하는 것을 방지하는지 주목해야한다. word.h가 진정으로 필요하지 않더라도 이러한 방식을 통해 모든 헤더파일을 보호하는 것은 좋은 습관이다.

line.h 파일은 word.h처럼 짧지 않다. 위에서 메인 루프의 간략한 개요를 보았었는데, 아래의 기능을 수행하는 함수가 필요하다.

  • 정렬(justification)없이 line buffer의 내용을 쓰는 것(write)
  • 얼마나 많은 문자를 line buffer에 남길 것인지 결정하는 것
  • line buffer를 정렬하여 내용을 쓰는 것(write)
  • line buffer을 비우는 것
  • line buffer에 단어를 추가하는 것

우리는 이 함수들을 flush_line, space_remaining, write_line, clear_line, add_word이라고 부를 것이다. line.h 헤더파일은 아래와 같은 모습을 보일 것이다.
line.h

#ifndef LINE_H
#define LINE_H

/************************************************************
 * clear_line: Clears the current line.                     *
 ************************************************************/
void clear_line(void);

/************************************************************
 * add_word: Adds word to the end of the current line.      *
 *           If this is not the first word on the line,     *
 *           puts one space before word.                    *
 ************************************************************/
void add_word(const char *word);

/************************************************************
 * space_remaining: Returns the number of characters left   *
 *                  in the current line.                    *
 ************************************************************/
int space_remaining(void);

/************************************************************
 * wrtie_line: Writes the current line with                 *
 *             justification.                               *
 ************************************************************/
void write_line(void);

/************************************************************
 *  flush_line: Writes the current line without             *
 *              justification. If the line is empty, does   *
 *              nothing.                                    *
 ************************************************************/
void flush_line(void);

#endif

word.cline.c 파일을 작성하기 전에, 우리는 메인 프로그램인 justify.c를 작성하기 위해 word.hline.h에 선언된 함수를 사용할 수 있다. justify.c를 작성하는 것은 대부분 우리가 생각한 원래의 루프 설계를 C언어로 번역하는 문제이다.
justify.c

/* Formats a file of text */

#include <string.h>
#include "line.h"
#include "word.h"

#define MAX_WORD_LEN 20

int main(void)
{
    char word[MAX_WORD_LEN+2];
    int word_len;
    
    clear_line();
    for (;;)
    {
        read_word(word, MAX_WORD_LEN+1);
        word_len = strlen(word);
        if (word_len == 0)
        {
            flush_line();
            return 0;
        }
        if (word_len > MAX_WORD_LEN)
            word[MAX_WORD_LEN] = '*';
        if (word_len + 1 > space_remaining())
        {
            write_line();
            clear_line();
        }
        add_word(word);
    }
}

line.hword.h를 둘다 포함하는 것은 컴파일러가 justify.c를 컴파일할 때 각각의 파일에 있는 함수 prototype에 접근할 수 있도록 해준다.
main은 20 글자를 넘는 단어들을 조작하는 것에 대해 트릭을 사용한다. read_word가 호출되었을 때, main은 21 글자를 넘어가는 어떠한 단어든 잘라내도록 한다(truncate). read_word가 반환된 후, mainword가 20글자보다 긴 문자열을 가지고 있는지 확인한다. 만약 그렇다면 읽힌 단어는 반드시 최소 21 글자 이상을 가지고 있는 것이고, 그래서 main은 21번째 문자를 별표(*)로 대체한다.
이제 word.c를 작성할 시간이다. 지금은 word.h 헤더파일이 read_word 하나의 함수에 대한 prototype만 가지고 있지만, 만약 필요하다면 word.c에 다른 추가적인 함수를 넣을 수도 있다. 우리가 read_char라는 작은 "도움을 주는" 함수를 추가한다면, read_word가 더 작성하기 쉬워진다. read_char가 단일 문자를 읽는 작업을 하도록 하고, 만약 개행문자나 탭(tab)을 발견한다면, 이를 공백(space)로 변경하도록 하자. read_wordgetchar대신에 read_char를 호출하는 것으로 개행문자와 탭을 공백으로써 처리하는 문제를 해결할 수있다.
word.c

#include <stdio.h>
#include "word.h"

int read_char(void)
{
    int ch = getchar();
    
    if (ch == '\n' || ch == '\t')
        return ' ';
    return ch;
}

void read_word(char *word, int len)
{
    int ch, pos = 0;
    
    while ((ch = read_char()) == ' ')
        ;
    while (ch != ' ' && ch != EOF)
    {
        if (pos < len)
            word[pos++] = ch;
        ch = read_char();
    }
    word[pos] = '\0';
}

read_word에 대해 토론하기전에, read_char 함수 내부에서 getchar의 사용과 관련하여 두 개의 코멘트가 있다. 일단 getcharchar 값 대신에 int값을 반환한다는 것이다. 이것이 read_char 내부의 변수 chint형 자료형으로 선언된 이유이고, read_char의 return type이 int인 이유이다. 또한, getchar는 더이상 읽을 수 없을 때 EOF 값을 반환한다.(일반적으로 input 파일의 끝에 도달했을 때)
read_word는 두개의 루프로 구성이 되어있다. 첫번째 루프는 공백을 스킵하는 것으로 처음으로 공백이 아닌 문자가 나오는 지점에서 멈춘다. (EOF는 공백이 아니기 때문에, input 파일의 끝에 도달했다면 루프가 멈춘다) 두번째 루프는 공백(space)이나 EOF를 마주칠때 까지 문자를 읽는다. len 한계에 도달에 도달할 때까지 word 내부에 문자들을 저장한다. read_Word 내부의 마지막 구문은 단어가 null 문자로 끝나도록 해주어 word가 문자열임을 나타낸다. 만약 read_word가 공백이 아닌 문자를 찾기 전에 EOF를 마주쳤다면, pos는 0으로 끝날 것이고 word가 빈 문자열이 될 것이다.
남은 파일은 이제 line.c이다. line.cline.h 파일에 선언된 함수의 정의를 가져야하고, line.c는 line buffer의 상태를 추적하는 변수를 가지고 있어야 한다. 변수 line은 현재의 행에 문자를 저장할 것이다. 엄밀히 말해서, 우리가 필요한 변수는 오직 line뿐이다. 그렇지만 속도와 편리함을 위해 두개의 또다른 변수인 line_len(현재 행의 문자 수)과 num_words (현재 행의 단어 수)를 사용할 것이다.
line.c

#include <stdio.h>
#include <string.h>
#include "line.h"

#define MAX_LINE_LEN 60

char line[MAX_LINE_LEN+1];
int line_len = 0;
int num_words = 0;

void clear_line(void)
{
    line[0] = '\0';
    line_len = 0;
    num_words = 0;
}

void add_word(const char *word)
{
    if (num_words > 0)
    {
        line[line_len] = ' ';
        line[line_len+1] = '\0';
        line_len++;
    }
    strcat(line, word);
    line_len += strlen(word);
    num_words++;
}

int space_remaining(void)
{
    return MAX_LINE_LEN - line_len;
}

void write_line(void)
{
    int extra_spaces, spaces_to_insert, i, j;
    
    extra_spaces = MAX_LINE_LEN - line_len;
    for (i = 0; i < line_len; i++)
    {
        if (line[i] != ' ')
            putchar(line[i]);
        else
        {
            spaces_to_insert = extra_spaces / (num_words - 1);
            for (j = 1; j <= spaces_to_insert + 1; j++)
                putchar(' ');
            extra_spaces -= spaces_to_insert;
            num_words--;
        }
    }
    putchar('\n');
}

void flush_line(void)
{
    if (line_len > 0)
        puts(line);
}

line.c 내부의 대부분의 함수는 작성하기 쉽다. 트릭은 write_line하나인데, 행을 정렬해서 쓴다(write). write_lineline 내부에 문자를 한글자씩 쓴다. 각각의 단어들의 쌍 사이에 추가적인 공백(space)이 필요하다면 공백에서 멈춘다. 추가적인 공백의 개수는 spaces_to_insert에 저장되는데, extra_spaces / (num_words - 1)의 값을 가지고, extra_spaces는 최대 행의 길이와 실제 행의 길이의 차이값이다. extra_spacesnum_words는 각 단어가 출력된 이후에 바뀌는데, spaces_to_insert또한 바뀐다. 만약 extra_spaces가 처음에 10이고, num_words가 5라면, 첫번째 단어에는 2개의 추가적인 공백이 있을 것이고, 두번째도 2개의 공백, 3번째는 3개의 공백, 4번째는 3개의 공백이 있을 것이다.

4. Building a Multiple-File Program

Section 2.1에서 우리는 하나의 파일에 있는 컴파일하고 링크하는 과정을 알아보았었다. 여러 개의 파일을 가진 프로그램에 대해서 확장시켜보자. 큰 프로그램을 빌드하는 것은 작은 것을 빌드하는 것과 동일하게 기본적인 단계를 필요로 한다.

  • 컴파일(Compiling) : 프로그램 내부의 각각의 소스파일은 반드시 개별적으로 컴파일되어야 한다.(헤더파일이 컴파일될 필요는 없다. 헤더파일의 내용은 헤더파일을 포함하고 있는 소스파일이 컴파일될 때 자동으로 컴파일된다.) 각각의 소스파일에 대해, 컴파일러는 오브젝트 코드(object code)를 가지고 있는 파일을 생성한다. 오브젝트 파일(object file)로 알려진 이 파일은 UNIX에서 .o의 확장자를 가지고, Windows에서 obj의 확장자를 가진다.
  • 링크(Linking) : 링커는 실행 가능한 파일(executable file)을 만들기 위해 이전 단계에서 생성된 오브젝트 파일들을 라이브러리 함수에 대한 코드를 함께 결합시킨다. 또 링커는 컴파일러가 남긴 외부 참조를 해결해야할 의무가 있다. (또다른 파일에 정의된 함수를 호출할 때나 또다른 파일에 정의된 변수에 접근할 때 외부 참조가 발생한다)

대부분의 컴파일러는 하나의 단계로 프로그램을 빌드할 수 있도록 해준다. 예를 들어 GCC 컴파일러로, Section 15.3의 justify 프로그램을 빌드하기 위한 커맨드는 아래와 같다.

gcc -o justify justify.c line.c word.c

세 개의 소스파일은 처음에 오브젝트 코드로 컴파일된다. 오브젝트 파일은 그 후 자동적으로 링커에게 전달되고, 하나의 파일로 결합한다. -o 옵션은 실행 파일(executable file)의 이름을 justify로 하길 원할 때 명시한다.

Makefiles

모든 소스파일의 이름을 커맨드라인에 넣는 것은 빠르게 지루해질 것이다. 아주 가끔이지만, 만약 최근에 변경한 사항으로 인해 영향을 받는 것이 하나가 아니라서 모든 소스파일을 다시 컴파일 해야하고 프로그램을 다시 빌드해야할 때 많은 시간을 낭비할 수도 있다.
큰 프로그램을 빌드하는 것을 더 쉽게 하기 위해, UNIX는 makefile이라는 개념을 만들었다. makefile은 프로그램을 빌드하기 위해 필요한 정보들을 가지고 있는 파일이다. makefile은 프로그램의 일부인 파일의 목록만 가지고 있는 것이 아니고, 파일의 의존성(dependencies) 또한 설명한다. bar.h 파일을 포함한 foo.c 파일을 생각해보자. 우리는 foo.cbar.h에 "의존한다"라고 말하기 때문에 bar.h를 수정하는 것은 foo.c를 다시 컴파일하는 것을 필요로 한다.
justify 프로그램에 대한 UNIX makefile을 아래에 서술해놓았다. makefile은 컴파일과 링크 과정에 GCC를 사용한다.

justify: justify.o word.o line.o
        gcc -o justify justify.o word.o line.o
        
justify.o: justify.c word.h line.h
        gcc -c justify.c

word.o: word.c word.h
        gcc -c word.c

line.o: line.c line.h
        gcc -c line.c

네 개의 행의 그룹이 있다. 각각의 그룹은 규칙(rule)로 알려져있다. 첫째줄에 있는 각각의 규칙은 대상 파일(target file)이 주어지고, 그 파일이 어디에 의존하는지 나타낸다. 두번째 행은 대상(target)이 의존하는 파일의 수정때문에 다시 빌드될 필요가 있을 때 실행될 커맨드이다.
첫번째 규칙에서는, justify(executable file)가 target이다.

justify: justify.o word.o line.o
        gcc -o justify justify.o word.o line.o

첫번째 행은 justifyjustify.o, word.o, line.o에 의존한다는 것을 나타낸다. 만약 마지막으로 빌드 되었을 때로부터 이 3개의 파일 중 어떤 것이라도 수정된다면 justify는 다시 빌드될 필요가 있다. 아래의 행에 나타낸 커맨드는 어떻게 다시 빌드(rebuilding)가 이루어지는지 보여준다.(gcc 커맨드를 사용하는 것으로 3개의 오브젝트 파일을 링크시킬 수 있음)
두번째 규칙에선 justify.o가 target이다.

justify.o: justify.c word.h line.h
        gcc -c justify.c

첫번째 행은 justify.c, word.h, line.h에 변경점이 있을 때 justify.o가 다시 빌드될 필요가 있다는 것을 나타낸다(word.hline.h를 언급한 이유는 justify.c가 이 파일들을 포함하고 있고, 그렇기 때문에 두 개 파일의 변화에 잠재적으로 영향을 받기 때문이다). 다음 행은 justify.o가 어떻게 업데이트되는지 나타낸다(justify.c를 재컴파일하는 것으로). -c 옵션은 컴파일러가 링크 시도를 하지 않고 justify.c를 오브젝트 파일로 컴파일하도록 한다.
일단 우리가 프로그램에 대한 makefile을 만들고 나면, 우리는 make 유틸리티를 프로그램 빌드하는 것에 사용할 수 있다. 프로그램 내부의 파일과 관련된 시간과 날짜를 확인하는 것으로make는 파일이 out of date인지 결정할 수 있다. 그 후 make는 프로그램을 재빌드할 때 필요한 커맨드를 보여줄 것이다.
만약 make을 시도할 것이라면 알아야하는 몇 가지 세부사항이 있다.

  • makefile 내부의 각각의 커맨드는 일련의 공백(space)이 아니고 탭(tab) 문자가 선행되어야 한다.(우리의 예시에서는, 8개의 공백으로 들여쓰기 된 형태로 커맨드가 나타났는데, 이것은 실제로 단일의 탭 문자이다.)
  • makefile은 일반적으로 Makefile(또는 makefile)이라는 이름의 파일안에 저장된다. make 유틸리티가 사용되었을 때, 이 이름이 해당하는 파일이 있는지 현제 디렉토리에서 자동적으로 확인할 것이다.
  • make를 불러오기 위해, 아래의 커맨드를 사용하면 된다.
make target

target은 makefile에 있는 target의 목록 중 하나이다. justify를 우리의 makefile을 사용해 실행가능하도록 빌드하기 위해, 아래의 커맨드를 사용하면 된다.

make justify
  • 만약 make를 불러올 때 어떠한 target도 존재하지 않는다면, 첫번째 규칙의 target을 빌드할 것이다. 예를 들어 아래의 커맨드는 justify를 실행 가능하도록 빌드할 것인데, 왜냐하면 justify는 우리의 makefile에서 첫번째 target이기 때문이다.
make

make는 책의 전체에서 여기에 대해서 작성해야할 정도로 충분히 복잡하다. 그래서 우리는 여기에 대한 복잡함을 탐구하려는 시도는 하지 않을 것이다. 진짜 makefile은 일반적으로 우리의 예제처럼 이해하기 쉽지가 않다. makefile에서의 중복성을 줄이고 수정하기 쉽게 만드는 많은 기술들이 있다. 그렇지만 이 기술들은 가독성을 대단히 떨어뜨리기도 한다.

모든 사람이 makefile을 사용하는 것은 아니다. 통합 개발 환경(IDE)에 의해 지원되는 "프로젝트 파일"을 포함하는 다른 프로그램 유지보수 도구들도 또한 유명하다.

Errors During Linking

컴파일 도중에 발견되지 않을 수도 있는 어떤 에러들은 링크 과정에서 발견될 수 있다. 특히, 만약 함수 또는 변수의 정의가 프로그램에서 찾을 수 없을 경우 링커는 이 정의에 대한 외부 참조를 해결할 수 없기 때문에 "undefined symbol"이나 "undefined reference"와 같은 오류를 발생시킬 것이다.
링커에 의해 발견되는 에러는 일반적으로 고치기 쉽다. 아래에 일반적인 오류들이 있다.

  • 오타(Misspellings) : 만약 변수나 함수의 이름에 오타가 있다면, 링커는 이것이 없다고 보고할 것이다. 예를 들어 프로그램에서 정의된 read_charread_cahr처럼 호출된다면, 링커는 read_cahr이 없다고 보고할 것이다.
  • 파일이 없음(Missing files) : 만약 링커가 파일 foo.c에 있는 함수를 찾지 못했다면, 파일에 대해 알지 못하는 것일 수도 있다. makefile이나 프로젝트 파일을 확인하여 foo.c가 목록에 있는지 확인해야한다.
  • 라이브러리가 없음(Missing libraries) : 링커가 프로그램에 사용된 모든 라이브러리 함수를 찾을 수 없을 수도 있다. 기본적인 예시는 <math.h> 헤더를 사용하는 UNIX 프로그램에서 발생하는 것이다. 단순히 프로그램에 헤더를 포함하는 것으로는 충분하지 않다. UNIX의 다양한 version은 프로그램이 링크될 때 -lm 옵션이 명시되는 게 필요한다, -lm은 링커가 <math.h> 함수가 컴파일된 version인 시스템 파일을 검색하게 한다. 이 옵션을 사용하지 않는다면 "undefined reference"와 같은 메시지가 링크 과정에서 발생한다.

Rebuilding a Program

프로그램의 개발 도중에, 프로그램의 모든 파일을 컴파일해야할 상황은 드물다. 대부분, 우리가 프로그램을 검사하고, 수정하고, 프로그램을 다시 빌드할 것이다. 시간을 절약하기 위해서는, 다시 빌드하는 과정은 마지막의 수정에 영향을 받는 파일들만 다시 컴파일하는 것이다.
Section 15.3의 개요에 있는 방법으로 각각의 소스파일에 헤더파일이 포함된 프로그램을 설계했다고 가정해보자. 수정 이후에 다시 컴파일해야되는 파일이 얼마나 많은지 보기 위해, 우리는 두 가지 가능성을 필요로 한다.
첫번째 가능성은 수정한 것이 단일의 소스파일에 영향을 미치는 경우이다. 이 경우에는, 이 파일이 반드시 다시 컴파일되어야 한다.(당연하게도 이후에 전체 프로그램이 다시 링크된다.) justify 프로그램을 생각해보자. 우리가 word.c에 있는 함수인 read_char를 압축하기로 결정했다고 가정해보자.

int read_char(void)
{
    int ch = getchar();
    
    return (ch == '\n' \\ ch == '\t') ? ' ' : ch;
}

이러한 수정은 word.h에 영향을 미치지 않기 때문에, 우리는 오직 word.c만 다시 컴파일하고 프로그램을 다시 링크하면 된다.

두번째 가능성은 수정한 것이 헤더파일에 영향을 미칠때이다. 이 경우에는, 헤더파일을 포함하고 있는 모든 파일을 다시 컴파일해야하는데, 왜냐하면 모든 파일들이 잠재적으로 수정한 것의 영향을 받기 때문이다.
예를 들어, justify 프로그램 내부의 read_word 함수를 생각해보자. main함수가 읽은 단어의 길이를 결정하기 위해 read_word를 호출한 이후에 바로 strlen을 호출했음에 주목하자. read_word가 이미 단어의 길이를 알고 있기 때문에(read_wordpos 변수가 길이를 추적함), strlen을 사용하는 것은 바보같아보인다. read_word가 단어의 길이를 반환하게 수정하는 것은 간단하다. 첫번째로, 우리는 word.h 내부의 read_word의 prototype을 수정한다.

/************************************************************
 * read_word: Reads the next word from the input and        *
 *            stores it in word. Makes word empy if no      *
 *            word could be read because of end-of-file.    *
 *            Truncates the word if its length exceeds      *
 *            len. Returns the number of characters         *
 *            stored.                                       *
 ************************************************************/
int read_word(char *word, int len);

당연하게도, 우리는 read_word의 주석을 변경하는 것에 주의를 기울여야한다.
다음으로, word.c에 있는 read_word의 정의를 수정한다.

int read_word(char *word, int len)
{
     int ch, pos = 0;
     
     while ((ch = read_char()) == ' ')
         ;
     while (ch != ' ' && ch != EOF)
     {
         if (pos < len)
             word[pos++] = ch;
         ch = read_char();
     }
     word[pos] = '\0'
     return pos;
}

마지막으로, justify.c에서 <string.h>의 포함을 제거하고 main 함수를 수정해야 한다.

int main(void)
{
     char word[MAX_WORD_LEN+2];
     int word_len;
     
     clear_line();
     for (;;)
     {
         word_len = read_word(word, MAX_WORD_LEN+1);
         if (word_len == 0)
         {
             flush_line();
             return 0;
         }
         if (word_len > MAX_WORD_LEN)
             word[MAX_WORD_LEN] = '*';
         if (word_len + 1 > space_remaining())
         {
             write_line();
             clear_line();
         }
         add_word(word);
     }
}

일단 이러한 수정을 하고 나면, 우리는 word.cjustify.c를 다시 컴파일하고 다시 링크하는 것으로 justify 프로그램을 다시 빌드할 것이다. word.h를 포함하지 않는 line.c를 다시 컴파일할 필요는 없으므로 우리가 수정한 것이 line.c에는 영향을 미치지 않을 것이다. GCC 컴파일러을 통해 아래와 같은 커맨드를 사용하는 것으로 프로그램을 다시 빌드할 수 있다.

gcc -o justify justify.c word.c line.o

line.c 대신 line.o를 언급한 것을 주의해야 한다.
makefile을 사용하는 것의 이점 중 하나는 다시 빌드하는 것이 자동적으로 조작(handled)된다는 것이다. 각각의 파일의 날짜를 검사하는 것으로, make 유틸리티는 마지막으로 빌드된 프로그램으로부터 어떤 것이 수정되었는지 결정할 수 있다. 그리고 간접적이든 직접적이든 이 파일들에 의존하는 다른 모든 파일과 함께 다시 컴파일한다. 우리가 word.h, word.c, justify.c를 수정한 것을 나타내고 justify 프로그램을 다시 빌드한다면, make는 아래와 같은 행동을 수행할 것이다.
1. justify.c를 컴파일하는 것으로 justify.o를 빌드한다.(justify.cword.h가 수정되었기 때문에)
2. word.c를 컴파일하는 것으로 word.o를 빌드한다.(word.cword.h가 수정되었기 때문에)
3. justify.o, word.o, line.o를 링크하는 것으로 justify를 빌드한다.(justify.oword.o가 수정되었기 때문에)

Defining Macros Outsided a Program

C 컴파일러는 일반적으로 프로그램이 컴파일될 때 macro의 값을 명시하는 방법들을 제공한다. 이 능력은 프로그램의 파일들을 수정하지 않고 macro의 값을 바꾸기 쉽게 만든다. 특히 프로그램이 makefile을 사용하여 자동적으로 빌드될 때 가치있다.
대부분의 컴파일러는 (GCC를 포함하여) -D 옵션을 지원하는데, 이 -D 옵션은 macro의 값을 커맨드 라인에 명시할 수 있다.

gcc -DDEBUG=1 foo.c

이 예제에서, DEBUG macro는 프로그램 foo.c 내부에서 1의 값을 가지는 macro로 정의된다. 마치 아래의 행이 foo.c의 시작부분에 나온 것처럼말이다.

#define DEBUG 1

만약 -D 옵션이 값을 명시하지 않고 macro의 이름을 사용한다면, 그 macro의 값은 1이 된다.
많은 컴파일러들은 또한 -U 옵션을 제공하는데, 이는 #undef를 사용한 것처럼 macro를 "undefines"한다. 우리는 -U를 사용하는 것으로 이미 정의된 매크로를 undefine하거나 -D를 사용해 일찍이 커맨드 라인에 정의된 것을 undefine 할수있다.



Others


소스파일을 포함하기 위해 #include directive를 사용한 예시는 없는 것 같다. 만약 이렇게 한다면 무슨일 일어나는가?

이게 규칙에 어긋나는 것은 아닌데, 좋은 습관은 아니다. bar.cbaz.c내부에서 필요한 함수 ffoo.c에 정의했다고 생각해보자. 우리는 bar.cbaz.c 내부에 아래와 같은 directive를 넣을 것이다.

#include "foo.c"

각각의 파일들은 아주 잘 컴파일될 것이다. 문제는 나중에 나타나는데, 링커가 f에 대한 2개의 오브젝트 코드의 복사본을 발견할 때이다. 당연하게도, bar.cfoo.c를 포함하고, baz.cfoo.c를 포함하지 않았다면 이 문제로부터 벗어날 수 있었을 것이다. 이 문제를 해결하기 위해, #include는 소스파일이 아닌 오직 헤더파일에만 사용하는 것이 가장 좋다.


#include directive에 대해 실제로 검색하는 규칙이 무엇인가?

무슨 컴파일러를 쓰느냐에 따라 달라진다. C표준에서는 #include의 설명이 의도적으로 분명치 않게(vague) 되어있다. 만약 파일의 이름이 괄호(<,>)로 둘러싸여있다면, 전처리기는 이를 "implementation-defined place의 연속"이라고 볼 것이다. 만약 파일의 이름이 큰따옴표 표시(")로 둘러싸여 있다면, 그 파일은 "implementation-defined manner로 검색"되고, 만약 찾지 못했다면 그 후 파일의 이름이 괄호(<,>)로 둘러싸여있는 것과 같은 방식으로 검색할 것이다. 명확한 답 없이 이렇게 많은 정보를 제공하는 것에 대한 이유는 간단하다. 모든 operating system이 계층적인 파일 시스템(tree 같은)을 가지지 못했기 때문이다.
설상가상으로 더 흥미로운 것은, C표준에서는 괄호(<,>)로 둘러싸인 이름이 파일 이름일 필요가 전혀 없다고 말하고, <>를 사용한 #include directive가 컴파일러에 의해 전체적으로 조작되도록 하는 가능성을 남겨두었다.


왜 각각의 소스파일이 자신만의 헤더파일을 필요로 하는지 이해하지 못하겠다. macro 정의, type 정의, 함수 prototype을 모두 포함한 큰 헤더파일은 안되는 것인가? 이 파일을 포함하는 것으로 각각의 소스파일은 모든 필요한 공유된 정보들에 대해 접근할 수 있을 것이다.

"하나의 큰 헤더파일"이라는 접근 방식은 확실히 작동하는 방식이다. 실제로 많은 프로그래머들이 사용한다. 그리고 이점도 가지고 있는데, 하나의 헤더파일만 가지고 있으므로 관리할 파일이 적다. 하지만 큰 프로그램에서는, 이 접근방식의 단점이 이 장점을 상쇄하고 넘어설 정도로 크다.
단일 헤더파일을 사용하는 것은 나중에 프로그램을 읽는 사람에게 유용한 정보를 제공하지 못한다. 여러 개의 헤더파일을 사용하면, 특정 소스파일에서 프로그램의 다른 부분을 사용하는 것을 빠르게 볼 수 있다.
이게 전부가 아니다. 각각의 소스파일이 큰 헤더파일에 의존한다면, 수정이 발생했을 때 모든 소스파일이 다시 컴파일되어야 할 것이다. 이것은 큰 프로그램에서 가장 큰 단점이다. 설상가상으로 , 그 헤더파일이 빈번하게 수정될 것인데, 헤더파일이 가진 정보가 아주 많기 때문이다.


extern int a[];

extern int *a;

공유된 배열이 전자와 같은 형태를 따라야 한다고 했었다. 그러면 배열과 포인터는 밀접한 관계성을 가졌기 때문에 후자처럼 작성하는 것이 규칙에 맞는가?

아니다. 표현식 내부에서 사용되었을 때, 배열은 포인터로 "변환(decay)"된다. (우리는 배열의 이름이 함수 호출에서 argument로써 사용되었을 때 이 행동을 주목했었다.) 그러나 변수 선언에서는, 배열과 포인터는 구분되는 자료형을 가진다.


실제로 필요하지 않은 헤더파일을 소스파일에 포함하는 것은 소스파일에 손상을 주는가?

헤더파일이 소스파일에 있는 선언이나 정의와 충돌하는 경우를 제외하고는 그렇지 않다. 충돌하지 않았을 때 일어날 수 있는 가장 안좋은 상황은 소스파일을 컴파일할 때 조금 더 시간이 걸릴 수 있다는 것이다.


파일 foo.c 내부의 함수를 호출할 필요가 있어서, 일치하는 헤더파일인 foo.h 포함하였다. 프로그램이 컴파일되었지만 링크가 되지 않는데 왜 그러는 것인가?

C언어에서 컴파일과 링크는 완벽하게 분리되어있다. 헤더파일은 링커가 아닌 컴파일러에게 정보를 제공하기 위해 존재한다. 만약 foo.c 내부의 함수를 호출하고 싶다면, foo.c를 컴파일해야 하고, 링커가 함수를 찾기 위해 foo.c에 대한 오브젝트 파일을 반드시 검색할 수 있도록 해야한다. 보통 이는 프로그램의 makefile이나 프로젝트 파일 내부에 foo.c의 이름을 지정하는 것을 의미한다.


만약 프로그램이 <stdio.h>내부의 함수를 호출한다면, 이는 <stdio.h>내부의 모든 함수가 프로그램과 함께 링크된다는 것을 의미하는 것인가?

그렇지 않다. <stdio.h>(또는 다른 헤더파일)를 포함하는 것은 링크에 어떠한 영향도 미치지 않는다. 어떠한 상황이든, 대부분의 링커는 오직 프로그램에 실제로 필요로 하는 함수들에 대해서만 링크할 것이다.


어디서 make 유틸리티를 얻을 수 있는가?
make는 표준 UNIX 유틸리티이다. GNU version에서는 GNU Make로 알려져있는데 이는 대부분 Linux distributions 내부에 포함되어 있다. 또한 Free Software Foundation(www.gnu.org/software/make/)에서 직접적으로 확인할 수 있다.

profile
저장용

0개의 댓글