배열
배열은 동일한 타입의 변수들을 하나의 이름으로 묶어 놓은 것이다. 여러 개의 변수들을 하나의 배열에 모아 두면 개별 요소를 첨자로 액세스할 수 있으므로 반복적인 처리가 효율적이다. 모든 언어에는 배열이라는 것이 존재하며 원리나 용도는 거의 동일하다. C#의 배열도 기본 용도는 동일하지만 C++의 배열과는 틀린 점이 많으므로 C++에 익숙한 사람들은 차이점을 잘 숙지해둘 필요가 있다. 배열을 선언하는 형식부터 틀리다.

타입[ ] 변수명;

배열을 구성하는 요소의 타입과 배열임을 나타내는 [ ] 괄호를 쓰고 그 뒤에 배열의 이름을 쓴다. 임의의 타입이 배열의 요소가 될 수 있으므로 어떤 배열이든지 만들 수 있다. [ ] 괄호안에 아무 것도 없으면 1차원 배열이 되며 콤마가 하나 있으면 2차원 배열이 된다. 일반적으로 정리하자면 배열의 차수는 [ ] 괄호안의 콤마수 + 1이라고 할 수 있다. 다음이 여러 가지 배열의 선언예이다.

int[] arScore;              // 1차원 정수 배열
double[] arRate;              // 1차원 실수 배열
int[,] arSung;              // 2차원 정수 배열
string[,,] arName;       // 3차원 문자열 배열

arScore는 정수형 변수의 집합을 담을 수 있는 1차원 배열이며 arRate는 실수형의 배열이다. arSung은 괄호안에 콤마가 하나 있으므로 2차원의 정수 배열이며 arName은 콤마가 두 개 있으므로 3차원의 문자열 배열이다. 메모리가 허락하는 한까지 얼마든지 다차원의 배열을 선언할 수 있다.

배열 할당
배열 선언문은 어디까지나 배열형 변수만 선언할 뿐이지 배열 요소를 저장하기 위한 메모리까지 할당하는 것은 아니다. 배열에 요소들을 저장하려면 new 연산자로 필요한 양만큼의 메모리를 할당해야 한다. new 연산자 다음에 요소의 타입과 할당할 크기를 지정한다. 배열의 크기는 선언 시점이 아닌 할당 시점에 결정된다.

arScore = new int[5];
arSung = new int[2,3];

1차원 배열은 new int 다음에 하나의 크기만 지정하며 2차원 배열은 행과 열 두 개의 크기를 콤마로 구분하여 지정한다. arScore는 정수형 변수 5개를 저장할 수 있는 배열이 되며 arSung은 정수형 변수를 3개씩 2묶음, 그러니까 총 6개의 정수를 저장할 수 있는 2차원 배열이 된다. 배열과 배열 변수의 관계를 그림으로 그려 보면 다음과 같다.

배열은 참조형이므로 변수 자체가 실제 데이터를 저장하지 않는다. new 연산자에 의해 힙에 데이터 저장을 위한 별도의 공간이 할당되고 배열 변수는 이 위치만을 가진다. int[ ] arScore; 선언문은 정수형 배열을 위한 변수만 생성하며 new int[5]는 배열 요소를 저장할 메모리를 힙에 할당한다. 그래서 배열은 선언문 외에도 할당문이 필요하다.

배열 뿐만 아니라 모든 참조형은 new 연산자로 실제 메모리를 할당받아야 한다. new로 할당하기 전의 참조 변수는 아무 것도 참조하고 있지 않다는 의미의 null이라는 값을 가지는데 이 상태에서 변수를 사용하면 NullReferenceException 예외가 발생한다. 참조 변수의 디폴트 값은 null이며 사용하기 전에 반드시 할당되어야 한다. 선언문과 할당문을 두 줄에 따로 쓰는 것이 번거롭다면 한 줄로 합칠 수도 있다.

int [] arScore = new int[5];

이 선언문에 의해 정수형 배열이 선언되고 크기 5(총 20 바이트)의 메모리도 같이 할당된다. 배열은 new 연산자에 의해 실행중에 할당되므로 변수로 크기를 지정할 수도 있다.

int size;
size = 5;
int[] ar;
ar = new int[size];

정수형 변수 size를 5로 초기화하고 size만큼 ar 배열을 할당했으므로 이 배열의 크기는 5가 된다. size를 실행중에 사용자에게 입력받는다면 사용자가 입력한 크기만큼 배열이 할당될 것이다. 그러나 실행중에 배열의 크기를 결정할 수는 있지만 일단 할당되면 크기를 변경할 수는 없다. 실행중에 크기를 마음대로 변경할 수 있는 동적 배열이 필요하다면 ArrayList라는 컬렉션 클래스를 사용해야 한다.

배열은 할당만 할 뿐 일부러 해제할 필요는 없다. 닷넷 라이브러리에 쓰레기 수집 기능이 있어 더 이상 사용되지 않는 배열은 자동으로 해제되므로 쓰다가 버리면 그만이다. 이는 배열 뿐만 아니라 모든 참조형에 공통적으로 적용되는 사항이다. 만약 배열을 더 이상 사용하지 않는다는 것을 명시적으로 표시해 놓고 싶을 때는 null을 대입한다. 그러면 가비지 컬렉터가 한가할 때 이 배열이 사용한 메모리를 거두어 들인다.

배열의 초기값
힙에 할당되는 배열 요소는 디폴트 값으로 초기화된다. 여기서 디폴트 값이란 각 타입의 가장 무난한 값을 의미하는데 정수는 0, 논리형은 false, 참조형은 null이다. 사실 상수의 형태만 다르지 모두 0이라고 할 수 있다. 0 대신 원하는 값으로 초기화하려면 할당문 뒤쪽의 { } 안에 원하는 초기값을 콤마로 구분하여 나열하면 된다. 당연한 얘기겠지만 초기값들은 배열 요소의 타입과 일치하거나 아니면 적어도 호환되는 타입이어야 한다.

int[] arScore = new int[5] { 1, 2, 3, 4, 5 };

이 선언문에 의해 arScore는 크기 5의 정수형 1차 배열이 되고 각 요소는 1, 2, 3, 4, 5로 초기화된다. 이때 크기 5는 생략 가능한데 컴파일러가 초기값의 개수를 세어 보고 배열의 크기를 자동으로 결정할 수 있기 때문이다. 크기를 꼭 밝히고 싶다면 상수만 쓸 수 있으며 초기값 개수와도 정확하게 일치해야 한다. 컴파일러가 알아서 크기를 셀 수 있으므로 크기를 생략하는 것이 유리하며 초기값 개수가 늘어나도 크기를 변경할 필요가 없기 때문에 오히려 더 간단하다. 초기값 리스트가 있을 때는 이 문장을 좀 더 짧게 줄일 수 있다.

int[] arScore = { 1, 2, 3, 4, 5 };

new 연산자를 빼고 초기값 리스트만 나열하면 된다. 초기값이 있으므로 메모리가 할당되어야 한다는 것을 알 수 있으며 개수로부터 배열의 크기도 자동 계산된다. 배열의 초기값은 최초 할당할 때 딱 한 번만 지정할 수 있으며 할당된 후에는 지정할 수 없다. 다음 코드는 컴파일되지 않는다.

int[] arScore = new int[5];
arScore = { 1, 2, 3, 4, 5 };

만약 할당 후에 배열 요소들을 재초기화하고 싶다면 각 요소에 값을 일일이 대입하는 방법밖에 없다. 2차원 배열도 비슷한 방법으로 초기화한다.

string[,] arCity =
{
    {"서울", "용인", "수원", "의정부"},
    {"춘천", "홍천", "평창", "양구"},
    {"대전", "합덕", "논산", "당진"}
};

2차원 문자열 배열로 도별 도시의 이름을 저장했다. 초기화 리스트 전체를 { } 괄호로 싸고 각 행별로 다시 { } 괄호로 싼 후 안쪽 괄호안에 배열 요소의 초기값을 나열하면 된다. 컴파일러는 { } 괄호로 1차, 2차 첨자를 알아 내므로 중간의 { } 괄호를 생략해서는 안되며 각 행의 초기값 개수는 일치해야 한다.

배열 요소의 참조
배열 요소를 참조할 때는 [ ] 괄호안에 읽고자 하는 요소의 첨자를 적는다. 첨자는 꼭 상수일 필요는 없고 변수도 사용할 수 있다. 첨자는 항상 0부터 시작하며 최대 첨자는 항상 배열 크기보다 1 더 작다. 즉, 크기 N의 배열에서 유효한 첨자는 0 ~ N-1까지이다.

arScore[1] = 0;
arCity[1, 2] = "횡성";

1차 배열은 첨자가 하나밖에 없으므로 [ ] 괄호안에 첨자 하나만 적으면 된다. arScore[1]은 1번째 요소, 그러니까 두 번째 요소를 의미한다. 2차 배열은 첨자가 두 개 있으므로 [ ] 괄호안에 1차, 2차 첨자를 콤마로 구분하여 적어야 한다. arCity[1,2]는 1행 2열의 요소를 의미한다. 다음은 배열을 사용하는 가장 간단한 예제이다.



정수형 배열을 선언하고 크기 5로 할당했다. 그리고 루프를 돌며 각 요소값을 첨자의 두 배 되는 값으로 초기화했다. 값이 제대로 들어갔는지 확인하기 위해 콘솔에 요소값을 출력해 보았다. 실행 결과는 다음과 같다.

 



초기값의 수가 많지 않으므로 선언, 할당, 초기화 문장들을 다음 한 줄로 간단히 줄일 수도 있다. 실행 결과는 동일하다.

int[] ar ={ 0, 2, 4, 6, 8 };

만약 배열의 첨자가 배열 범위를 벗어나면 IndexOutOfRangeException 예외가 발생한다. 위 예제에서 ar[8] = 16; 대입식을 실행하면 예외를 일으키며 즉시 종료되어 버릴 것이다. 예외에 의해 코드의 잘못을 바로 발견할 수 있으며 이 예외를 받아 처리하면 첨자를 잘못 사용했을 때의 에러를 원하는대로 처리할 수 있다. 첨자가 범위를 벗어나는 경우는 보통 앞쪽에 뭔가 논리적인 버그가 있는 것이므로 반드시 수정해야 한다.

알다시피 C++은 배열의 첨자를 전혀 점검하지 않으며 첨자 연산이 포인터 연산으로 정의되어 있기 때문에 경계를 점검할 수도 없다. 메모리의 엉뚱한 곳을 건드리면 곧바로 다운되는 것도 아니고 언제 어떻게 죽을지 알 수 없는 불안정한 상태가 되므로 심각하고도 골치 아픈 버그를 초래한다. C++의 이런 특징은 명백하면서 너무나도 치명적인 단점이라고 할 수 있는데 C#에서는 이 문제가 획기적으로 개선되었다.

앞에서도 설명했다시피 사용한 배열을 해제할 필요는 없다. 필요할 때 할당해서 실컷 써 먹다가 그냥 내버려 두면 나머지 뒷처리는 가비지 컬렉터가 알아수 수행한다. C#의 배열은 C++과는 달리 함수의 인수로 전달할 수 있으며 배열을 리턴할 수도 있는데 이에 대해서는 함수 호출 형식을 연구할 때 같이 알아 볼 것이다.


배열의 메서드
배열은 System.Array 클래스로부터 상속받아 만들어진다. Array 클래스에는 배열을 관리하는 다음과 같은 메서드와 프로퍼티들이 포함되어 있다. 이 메서드들만 사용해도 배열에 대한 검색, 정렬 등의 기본적인 자료 관리가 가능하다.

메서드, 프로퍼티

설명

GetLength(n)

n 차원의 요소 개수를 조사한다.

GetUpperBound(n)

n 차원의 마지막 요소 첨자를 조사한다. 개수보다 항상 1 적다

Length

배열 요소의 개수를 조사한다. 모든 차수의 곱과 같다.

Rank

배열의 차수를 조사한다.

Sort

배열 요소들을 크기순으로 정렬한다. 일정 범위의 요소들만 정렬할 수도 있다.

Reverse

배열 요소들의 순서를 반대로 뒤집는다. 일정 범위의 요소들만 뒤집을 수도 있다.

BinarySearch

이분 검색으로 요소를 찾는다. 검색된 경우 첨자가 리턴된다. 메서드를 호출하려면 배열이 정렬되어 있어야 한다.

Clear

지정한 범위의 요소들을 삭제하여 기본값으로 만든다.


이중 가장 자주 사용되는 것은 배열 요소의 개수를 조사하는 Length 프로퍼티이다. 배열 요소 전체를 출력하고 싶을 때는 다음과 같이 쓰는 것이 원칙이다.

for (int i = 0; i < ar.Length; i++)
{
     Console.WriteLine(ar[i]);
}

앞의 예제에서는 배열을 순회할 때 5라는 상수를 직접 사용했었는데 이 경우 배열 크기가 변경되면 루프의 조건문도 같이 수정해야 하므로 불편하다. Length는 배열 요소의 개수를 실시간 조사하므로 배열 크기가 바뀌어도 순회 루프를 수정할 필요가 없다. 다차원 배열에서 특정 차원의 크기를 알고 싶을 때는 GetLength 메서드를 사용해야 한다. 다음 예제는 arScore에 저장된 성적을 정렬하여 출력한다.



arScore 배열에 몇 가지 성적값을 초기화해 놓았는데 실제 성적 처리 프로그램이라면 이 배열에 성적을 입력받는 코드가 있어야 할 것이다. Sort 메서드를 호출하면 배열의 요소들이 오름차순으로 정렬되어 작은 값이 앞쪽으로 오고 큰 값이 뒤쪽으로 이동한다. 성적은 1등부터 출력하는 것이 상식적이므로 Reverse 메서드로 배열 전체를 뒤집은 후 출력했다.



메서드와 프로퍼티들이 다양하게 준비되어 있기 때문에 이 정도의 간단한 자료 처리는 배열만으로도 충분하다.


배열의 배열
C# 배열의 요소 타입에는 제약이 없기 때문에 어떤 타입이나 배열 요소가 될 수 있다. 이 말은 곧 배열이 배열의 요소가 될 수 있다는 뜻이며 이렇게 만들어진 배열을 가변 배열 또는 배열의 배열이라고 부른다. 다차원 배열과는 의미가 다르며 선언하는 방법도, 메모리상에 할당되는 방식도 다르다. 다음 두 선언문을 비교해 보자.

int[,] ar2;               // 다차원 배열
int[][] aar;            // 배열의 배열

ar2는 2차원의 정수형 배열이며 aar은 정수형 배열을 요소로 가지는 배열이다. ar2의 요소는 두 말할 것도 없이 정수형이다. 하지만 aar의 직접적인 요소는 1차원 정수형 배열이며 이 배열안에 정수형 변수들이 포함되어 있다. 결과적으로는 둘 다 정수 여러 개를 저장한다는 점에서 같지만 메모리에 구현되는 모양은 상당히 다르다. 실제 코드를 보자.



다차원 배열인 ar2는 할당할 때 초기값의 개수에 따라 행과 열이 모두 결정된다. ar2의 초기화 리스트에는 정수가 2개씩 3묶음이 있으므로 3행 2열의 바둑판 모양으로 할당될 것이다. aar은 정수형 배열의 배열로 선언되었고 정수형 배열 3개분만큼의 메모리를 할당했다. 그리고 각 행에 대해 개별적으로 할당하되 초기값 개수를 각각 다르게 주었다. 두 배열의 모양을 그림으로 그려 보면 다음과 같다.

다차원 배열은 행과 열의 개수가 할당할 때 한번에 결정되므로 직사각형(Rectangular) 모양이다. 그러나 배열의 배열은 전체 배열의 각 요소인 부분 배열들을 개별적으로 할당할 수 있으므로 크기가 각각 다를 수 있으며 그래서 오른쪽 끝이 가지런하지 않고 들쭉 날쭉(Jagged)하다. 이 두 종류의 배열을 메모리에 구현된 모양 그대로 직사각형 배열, 들쭉 날쭉 배열이라고 부른다. 들쭉 날쭉한 배열도 선언과 동시에 초기화할 수 있다.

int[][] aar = new int[][]
{
    new int[] { 1, 2, 3, 4 },
    new int[] { 5, 6 },
    new int[] { 7, 8, 9, 10, 11, 12 },
};

세 개의 배열 초기값에 new 연산자를 사용한 할당문을 쓰고 그 뒤쪽에 배열의 초기값을 나열하면 된다. 들쭉 날쭉 배열의 요소를 참조할 때는 두 개의 [ ] 괄호를 사용하여 1차, 2차 첨자를 각각 지정해야 한다. 예제에서는 aar[0][1] 요소를 읽어 출력했는데 2가 출력될 것이다. aar[0,1]이라고 적어서는 안된다. 직사각형 배열에 비해서는 문법이 확실히 어렵고 난해하다.

일반적으로는 직사각형의 배열이 형태도 간단하고 쓰기에도 편하므로 대개의 경우 직사각형 배열이면 충분하다. 예를 들어 각 반의 성적을 저장한다면 반을 1차 첨자, 학생 번호를 2차 첨자로 하여 직사각형 배열을 할당하면 된다. 이때 2차 첨자는 학생이 최고 많은 반의 학생수에 약간의 여유분을 주어 결정한다. 뒤쪽에 조금 남는 공간이 생기기는 하지만 반별 학생 수가 비슷하기 때문에 낭비가 심하지는 않다.

그러나 부분 배열의 요소 개수가 현격하게 차이나는 경우 직사각형 배열은 낭비가 너무 심하다. 예를 들어 사람 이름을 가나다순 배열로 정의한다고 해 보자. 김가, 이가, 박가는 아주 흔하기 때문에 가, 바, 아의 요소는 굉장히 많이 필요하지만 라, 카, 타의 요소는 거의 없어 이 배열의 뒤쪽은 불필요한 공간만 차지할 것이다. 이럴 때 배열의 배열을 사용하면 각 부분 배열에 필요한만큼만 할당해서 알뜰하게 사용할 수 있다.
Posted by 코딩하는 야구쟁이
,