9-1-나.배열의 특징

배열은 기본형과는 달리 여러 개의 변수를 하나의 이름으로 모아 놓은 것이다. 그래서 기본형 변수들과는 다른 면이 많다. 또한 C의 배열은 파스칼이나 베이직같은 고급 언어의 배열과는 다른 독특한 면이 있다. C의 배열이 어떤 특징을 가지는지 순서대로 알아보도록 하자.

 

 배열 요소의 번호인 첨자는 항상 0부터 시작(Zero Base)한다. 컴퓨터의 세계에서는 0이 언제나 첫 번째 숫자이다. 첫 번째 요소의 첨자가 1번이 아니라 0번이기 때문에 마지막 요소의 첨자는 배열의 크기보다 항상 하나 더 작다. arInt 배열의 크기가 5라면 첫 번째 배열 요소는 arInt[0]이 되고 마지막 배열 요소는 arInt[4]가 된다. arInt[5]라는 요소는 존재하지 않는다.

실생활에 사용하는 자연수는 항상 1부터 시작하기 때문에 첨자의 번호가 실제 배열이 표현하고자 하는 대상과 일치하지 않는 경우가 있다. 예를 들어 학생 30명의 성적을 저장하려면 크기 30의 정수형 배열이 필요하다. 그래서 int ar[30];이라는 배열을 선언하는데 이때 생성되는 배열 요소는 ar[0]~ar[29]까지이므로 0번 첨자에 1번 학생의 성적을 저장하고 1번 첨자에 2번 학생의 성적을 저장해야 한다.

1번 학생의 성적을 1번 첨자에 넣으면 좋겠지만 배열의 시작이 0번부터이므로 n번 학생의 성적을 n-1번 요소에 저장해야 하는 것이다. 학생 번호와 배열 첨자가 일치하지 않기 때문에 가끔 혼란스러울 때도 있고 뜻하지 않게 실수를 할 경우도 있다. 만약 꼭 첨자 번호와 학생 번호를 일치시키고 싶다면 int ar[31]; 크기로 하나 더 여유있게 선언한 후 0번 첨자를 사용하지 않고 버리면 된다. 그러나 실제로 이렇게 하는 경우는 드문데 왜냐하면 개발자들은 일반인들과 달리 0이 정수의 시작이라는 개념에 익숙해져 있기 때문이다. 내부적 계산은 항상 0부터 시작하되 사용자와 상호작용할 때만 주의하면 된다.

 

n 번 학생의 성적을 출력할 때 : printf("%번 학생 성적은 %d입니다",n,ar[n-1]);

ar[n]을 출력할 때 : printf("%번 학생 성적은 %d입니다",n+1,ar[n]);

 

파스칼이나 최근의 스크립트같은 고급 언어들은 배열 첨자의 시작값을 사용자가 직접 지정할 수 있도록 되어 있지만 C는 그런 기능을 제공하지 않는다. 굳이 이유를 밝히자면 배열의 요소를 구하는 첨자 연산이 따로 정의되어 있지 않고 포인터를 사용한 간접적인 연산으로 정의되어 있기 때문이다. 첨자 연산이 단순해지면 배열을 참조하는 속도가 전반적으로 빨라지는데 C는 편의성보다는 성능 위주로 설계되어 있다.

 배열이 차지하는 총 메모리양은 배열의 크기에 배열 요소의 크기를 곱해서 구할 수 있다. 즉 배열의 총 크기는 sizeof(타입)*크기이다. int ar[5] 배열은 정수형 변수 다섯 개의 집합이므로 정수형의 크기인 4에 배열 크기 5를 곱한 결과인 20바이트를 차지한다. 배열 크기가 5라고 해서 5바이트를 차지하는 것이 아니다.

배열을 동일한 다른 배열에 복사하거나 모든 배열 요소를 특정한 값으로 채우고 싶을 때는 메모리 복사를 해야 하는데 이때 복사할 바이트 수를 정확하게 계산해야 한다. 배열의 총 바이트 수는 sizeof 연산자로 쉽게 구할 수 있으므로 이 연산자가 구해주는 크기를 사용하면 된다. 만약 배열의 크기, 그러니까 요소의 개수를 알고 싶다면 다음 공식을 사용한다.

 

배열 크기=sizeof(배열)/sizeof(배열[0]);

 

배열의 총 바이트 수를 배열 요소의 크기로 나누면 요소의 개수가 된다. 구체적인 예를 들자면 int ar[5]의 크기는 sizeof(ar)/sizeof(ar[0])가 되는데 sizeof(ar)이 배열의 총 바이트수인 20이고 ar[0]는 정수형이므로 4로 평가되며 전체 계산식은 5가 된다. sizeof 연산자는 컴파일시에 계산되므로 이 식은 컴파일할 때 이미 결정되는 상수이다. sizeof(ar)/sizeof(int) 도 상수끼리의 나눗셈이므로 마찬가지로 상수이다. 그래서 sizeof 연산자를 많이 쓴다고 해서 프로그램이 느려지지는 않는다.

배열은 선언할 때 크기를 지정하므로 이미 그 크기를 알고 있는데 왜 이런 공식이 필요할까? 배열의 크기값을 코드에서 상수로 바로 참조하면 배열 크기가 바뀔 때마다 코드를 수정해야 하지만 sizeof 연산식을 사용하면 컴파일러가 계산하므로 편리하다. 또한 배열 초기화 방법 중에 배열 크기를 생략하는 방법이 있는데 이렇게 정의한 배열 크기를 컴파일러가 알아서 구하도록 하기 위해 이 공식을 많이 사용한다.

 배열을 선언할 때 크기값은 반드시 상수로 주어야 한다. 하지만 배열이 일단 만들어지고 나면 첨자로 변수를 사용할 수 있다. ar 배열의 첫 번째 요소값을 알고 싶으면 ar[0]를 읽고 두 번째 요소의 값을 알고 싶으면 ar[1]을 읽으면 된다. 괄호안에 조사할 요소의 첨자 번호를 상수로 지정하는데 이 자리에 변수를 쓸 수도 있다. 만약 i라는 변수값을 첨자로 사용하고 싶다면 ar[i]로 읽으면 된다.

다른 변수를 첨자로 사용하여 배열 요소를 참조할 수 있다는 점이 배열의 가장 큰 장점이라 할 수 있다. 루프의 제어 변수를 첨자로 사용하면 배열이 아무리 크더라도 모든 배열 요소에 대해 반복적인 처리가 가능해진다. 다음 예는 ar 배열의 모든 요소를 0으로 만든다.

 

for (i=0;i<5;i++) ar[i]=0;

 

i가 0에서 4까지 반복되며 ar[i]에 모두 0을 대입함으로써 ar[0]부터 ar[4]까지 모두 0으로 초기화하는 코드이다. 다음 예제는 배열을 사용하여 학생 5명의 성적을 입력받아 배열에 저장한 후 총합과 평균을 구해 출력한다.

 

: Array

#include <Turboc.h>

 

void main()

{

     int arScore[5];

     int i;

     int sum;

 

     for (i=0;i<sizeof(arScore)/sizeof(arScore[0]);i++) {

          printf("%d번 학생의 성적을 입력하세요 : ",i+1);

          scanf("%d",&arScore[i]);

     }

 

     sum=0;

     for (i=0;i<sizeof(arScore)/sizeof(arScore[0]);i++) {

          sum+=arScore[i];

     }

 

     printf("\n총점은 %d점이고 평균은 %d점입니다.\n",

          sum,sum/(sizeof(arScore)/sizeof(arScore[0])));

}

 

실행해 보자. 성적 다섯 개를 입력하면 입력된 성적의 통계치를 구해 출력한다.

 

1번 학생의 성적을 입력하세요 : 78

2번 학생의 성적을 입력하세요 : 85

3번 학생의 성적을 입력하세요 : 69

4번 학생의 성적을 입력하세요 : 92

5번 학생의 성적을 입력하세요 : 70

 

총점은 394점이고 평균은 78점입니다.

 

학생수가 5명이므로 점수 배열 arScore의 크기도 5로 선언했다. i 제어변수로 0~4까지 루프를 돌며 학생 5명의 점수를 입력받아 arScore 배열에 저장한다. 학생 번호는 배열의 첨자 번호보다 항상 1더 크므로 입력 요구 메시지의 학생 번호는 i+1이어야 한다. 그렇지 않으면 있지도 않은 0번 학생의 성적을 입력하라는 메시지가 출력될 것이다.

5명의 성적을 입력받은 후 다시 루프를 돌면서 arScore 배열 요소의 총합을 구했다. 총합이 구해지면 평균은 총 학생수(=배열 크기)로 나누어 쉽게 구할 수 있다. 물론 이 예제의 경우 합계와 평균만 구하므로 배열을 쓸 필요없이 입력받는 족족 합계에 누적할 수도 있다. 하지만 입력받은 성적을 배열에 일단 저장해 놓으면 총합, 평균 뿐만 아니라 최대값, 최소값, 석차, 분산, 편차 등의 다양한 통계치를 구할 수 있게 된다.

이 예제는 앞에서 말한 sizeof 연산자로 배열 크기를 구해 그 크기만큼 루프를 돌고 총점을 나누고 있는데 그냥 5라고 바로 써도 일단은 상관없다. for (i=0;i<5;i++) 이라고 쓰는 것이 훨씬 더 짧고 간단해 보인다. 만약 이 예제를 확장해서 5명이 아닌 20명의 성적을 입력받도록 하고 싶다고 해 보자. 그러면 arScore의 크기를 20으로 늘리는 것뿐만 아니라 루프에 사용된 배열 크기와 총점을 나누는 제수도 다 20으로 바꿔야 할 것이다.

이 예제는 배열 크기를 세 군데서 참조하고 코드가 짧으니 수작업으로 편집할만 하겠지만 코드가 길어져서 배열 크기를 29군데서 참조하고 있다면 정말 끔직할 것이다. 실수로 하나를 수정하지 않고 5로 남겨 두면 불일치가 발생하여 오동작할 것이다. 그래서 내가 이미 배열 크기를 알고 있지만 sizeof 연산자로 배열 크기를 계산하는 것이다. 이렇게 해 두면 배열 크기가 바뀔 때 배열 선언문만 바꾸고 나머지 뒷처리는 컴파일러에게 맡겨 두면 된다.

 C언어는 배열의 범위를 전혀 점검하지 않는다. 이것은 배열의 특징이라기 보다는 다른 고급 언어와 구별되는 C언어의 고유한 특징이며 잘 알아둘 필요가 있다. 배열을 선언할 때 그 크기를 지정하도록 되어 있으므로 컴파일러는 배열 요소의 끝 번호를 알고 있다. 시작은 항상 0번이므로 int ar[5]의 시작은 ar[0]이고 끝은 ar[4]가 된다. 그렇다면 다음 문장은 어떻게 컴파일될까?

 

     int ar[5];

     ar[8]=1234;

 

ar의 크기가 5밖에 안되는데 ar[8]이라는 존재하지 않는 배열 요소에 어떤 값을 쓰려고 했다. 이렇게 코드를 작성하면 당연히 에러로 처리될 것 같지만 이 코드는 에러는 고사하고 경고 하나 없이 아주 잘 컴파일된다. 컴파일러가 배열의 범위를 점검하지 않기 때문이다. 심지어 ar[-68]같이 음수로 된 첨자를 사용해도 아무 이상이 없다. 물론 컴파일만 잘될 뿐이지 정상적으로 실행되지는 않을 것이다.

도대체 C 컴파일러를 만든 사람들은 왜 이런 기본적인 에러 처리도 하지 않았을까? 이렇게 만들어 놓은데는 다 이유가 있다. 배열 요소를 참조할 때마다 첨자 번호가 배열 크기보다 큰지, 작은지 점검해 보고 만약 범위를 벗어나면 에러나 경고로 처리할 수도 있다. 그러나 컴파일러가 이런 점검을 할 수 있는 경우는 첨자가 상수일 때뿐이다. 변수인 경우는 실행 시간에 범위를 점검하는 코드를 추가해야 하는데 매번 실시간으로 첨자의 유효성을 일일이 점검하려면 어쩔 수 없이 그만큼 실행 시간이 느려질 것이다. 배열의 강점은 무엇보다 신속한 요소 참조인데 이런 장점이 감소되는 것이다.

그래서 컴파일러는 배열 참조문에 대해 아무런 처리도 하지 않으며 배열의 범위를 점검하는 것은 고스란히 개발자의 몫으로 남겨져 있다. 개발자는 필요할 경우에 한해 첨자가 배열 범위안에 있는지 점검해 보되 대개의 경우 따로 점검할 필요가 없다. 앞에서 예로 든 성적 처리 예제를 보면 arScore의 첨자로 사용되는 i가 0~4까지 루프를 돌도록 되어 있어므로 절대로 범위를 벗어나지 않는다. 굳이 하려면 다음과 같이 첨자의 범위를 점검할 수 있을 것이다.

 

     for (i=0;i<5;i++) {

          if (i >= 0 && i < 5) {

               sum+=arScore[i];

          }

     }

 

조금만 살펴보면 느끼겠지만 이 조건 점검문은 실행시간만 까먹고 프로그램의 크기만 늘리는 전혀 불필요한 코드이다. C가 배열의 범위를 점검하지 않는 다른 이유도 있다. 다음에 포인터를 공부해 보면 알겠지만 ar의 크기가 5라고 해서 ar[8]이 전혀 의미가 없는 것은 아니며 마찬가지로 첨자로 음수를 쓰는 것도 일종의 특수한 기법으로 활용되기도 한다.

배열의 범위 점검을 하지 않는 것은 중급 언어로서 C의 성격을 잘 말해준다. 개발자의 부주의한 코딩 습관까지 컴파일러가 책임을 지지 않는 대신 그만큼 생성되는 코드는 작고 빨라지는 것이다. C의 이런 특징은 C언어를 사용하는 동안 항상 염두에 두어야 하는데 조금만 실수해도 프로그램이 다운될 위험이 있다. 다음 예제는 C로 만든 프로그램의 이런 위험성을 잘 보여준다.

 

: ArrayBound

#include <Turboc.h>

 

void main()

{

     char name[10];

 

     printf("이름이 뭐니? ");

     gets(name);

     printf("너가 바로 그 유명한 %s이구나.\n",name);

}

 

이름을 물어 보고 입력받은 이름을 다시 화면으로 출력해 주는 것밖에 없다. 사람의 이름은 보통 3자고 많아 봐야 4자이므로 공백과 널 문자까지 고려해서 10바이트이면 충분하다. 이 예제의 질문에 대해 "김 날씬", "황보 뚱뚱", "제임스딘" 정도로 입력하면 아무 문제가 없다. 하지만 10바이트를 넘는 이름인 "박 미인박명", "아놀드 슈왈츠제네거"를 입력해 보아라.

너무 긴 이름이 입력되어서 아마 당장 자살해 버릴 것이다. gets 함수는 키보드로 입력된 문자열을 입력받아 name 배열에 넣어주는데 name 배열의 크기 따위는 고려하지 않는다. 사실 gets는 시작 번지만 전달받을 뿐 name의 크기가 얼마나 되는지조차도 알 수 없으며 알아서 충분한 크기로 선언했겠거니 생각해 버린다. 이 프로그램의 오류는 사람의 이름을 입력받는 버퍼의 크기를 10밖에 주지 않았다는데 있다.

버퍼를 128이나 256정도로 충분히 늘리든가 아니면 gets 대신 버퍼의 크기를 전달받는 다른 함수(fgets)로 입력받아야 한다. 또는 scanf 함수의 %s 서식에 최대 입력 문자 수를 지정하는 방법을 쓸 수도 있는데 위 예제의 경우 scanf("%9s",name)이라고 쓰면 안전하다. 다음 예제도 역시 초보자들이 배열을 잘못 다루는 흔한 예중 하나이다.

 

: ArrayBound2

#include <Turboc.h>

 

void main()

{

     int i;

     int ar[5];

 

     i=1234;

     ar[5]=5678;

     printf("i=%d\n",i);

}

 

정수형 변수 i를 선언하고 1234 로 초기화했다. 그리고 크기 5의 정수형 배열ar을 선언했는데 이 배열의 크기가 5이므로 배열 끝 요소가 5라고 착각해서 ar[5]에 5678이라는 값을 대입했다. 배열 범위를 벗어났지만 컴파일러는 아무런 지적도 하지 않는다. 이 상태에서 i값을 출력해 보면 원래 대입했던 1234가 아닌 ar[5]에 대입된 5678이 출력될 것이다.

지역변수들은 선언된 순서대로 스택에 생성되는데 i가 ar 배열 다음에 인접하게 배치될 것이다. 그래서 ar[5]의 자리가 우연히 i가 기억된 스택 위치와 일치하게 되고 그래서 ar[5]에 대입된 값이 i의 값을 바꾸게 되는 것이다. 일부러 이런 효과를 노리는 경우는 없기 때문에 이는 단순한 실수라고 봐야 한다.

하지만 문제는 아무 경고나 에러가 없다는 점이며 이 프로그램은 i값만 비정상적으로 바뀌었을 뿐 다운되지도 않는다. i가 방패 역할을 한 것인데 만약 i가 아주 중요한 의미를 가지는 변수라면 이 프로그램은 제대로 동작할 수 없을 것이다. 일반적으로 메모리를 잘못 건드리면 곧바로 프로그램이 오동작하지 않으며 언제 오동작을 하게 될지 예측하기 무척 어렵다. 그래서 이런 부류의 버그가 골치아픈 것이다.

 

이상으로 배열의 중요한 특징 4가지에 대해 알아보았다. 이 외에 몇 가지 사소한 특징들이 더 있는데 관련 부분에서 논하게 될 것이다. 배열의 이런 특징은 C언어 자체를 이해하는데 큰 비중을 차지하므로 잘 알아 두도록 하자.