-
16장. 배열CLR Via C# 2022. 2. 28. 16:02
모든 배열 타입은 암묵적으로 System.Array 추상 클래스로부터 상속을 받으며, 이 타입은 또 다시 System.Object로부터 상속을 받는다. 모든 배열들은 참조 타입이며, 모든 배열 인스턴스들은 관리되는 힙 메모리 공간에 할당되고, 응용프로그램의 변수 또는 필드에 배열에 대한 참조가 저장되며 배열의 각 요소들이 직접 할당되는 것은 아니다.
배열의 메모리 블럭에는 배열의 요소들(값 혹은 참조) 뿐만 아니라 타입 객체 포인터, 동기화 블록 인덱스, 배열의 차원, 배열 내의 각 차원별 하한(거의 대부분 이 설정 값은 0일 것이다), 배열 내의 각 차원별 길이 등이 들어있다.
흔히 첫번째 요소가 인덱스 0부터 시작하는 1차원 배열(Zero-based Arrays), 이러한 배열을 흔히 SZ 배열, 또는 벡터라고 이야기하곤 한다. 벡터는 성능상 가장 뛰어난 이점을 제공하는데 벡터에 직접 대응되는 IL명령어가 있고 즉시 사용할 수 있기 때문이다.
만약 시작 인덱스가 0이 아닌 배열을 만들려면 Array.CreateInstance을 사용하면 된다.
배열 요소 초기화하기
C#에서는 두 번에 걸쳐서 해야 할 작업을 한 문장으로 끝낼 수 있는 문법을 지원하는데 다음의 예를 살펴보자.
String[] names = new String[] { "Lim", "Kim" };중괄호 안쪽에 쉼표로 구분된 토큰들의 집합을 배열 이니셜라이저라고 부른다. 각각의 토큰은 임의의 복잡한 표현식이 될 수 있으며, 다차원 배열의 경우 중첩하여 배열 이니셜라이저를 사용할 수도 있다. 앞의 예제에서는 단순히 두 개의 String을 지정하였다.
메서드 내부의 지역 변수를 정의하면서 배열 이니셜라이저를 사용하려는 경우, C#의 암묵적 타입의 지역 변수(var) 기능을 이용하여 다음과 같이 코드를 단순화하는 것도 가능하다.
// C#의 암묵적 타입의 지역 변수(var) 기능을 사용하였다. var names1 = new String[] { "Lim", "Kim" }; // C#의 var와 암묵적 타입의 배열 기능을 사용하여 배열을 생성한다. var names2 = new[] { "Lim", "Kim", null}; // 오류 발생! 암시적으로 타입화된 배열에 가장 적합한 타입이 없음. var names3 = new[] { "Lim" , "Kim" , 123 }; // C#의 var와 암묵적 타입의 배열, 그리고 익명 타입 기능을 사용한다. var kids = new[] {new { Name = "Lim" }, new { Name = "Kim" }};세번째에 오류가 발생하는 이유는 String과 int 사이의 공통점이 Object 타입이기 때문에 박싱을 하면 가능하다. 하지만 배열의 요소를 박싱하기에는 너무 비효율적이라 컴파일러가 에러를 보낸다.
배열 캐스팅하기
참조 타입 요소로 구성된 배열의 경우, CLR은 암묵적으로 원본 배열의 요소를 특정 타입으로 캐스팅할 수 있다. 이 경우 배열 타입은 반드시 동일한 차수를 지녀야 하고, 원본 요소 타입에서 대상 요소 타입으로 암묵적이든 명시적이든 변환이 가능해야 한다. 하지만 값 타입의 요소로 구성된 배열은 다른 타입으로 캐스팅할 수 없다. 그러나 Array.Copy 메서드를 사용하여, 새로운 배열을 만들고 원래의 배열 요소를 꺼내어 대상 배열로 수동으로 지정할 수는 있을 것이다.
Array.Copy는 다음과 같이 배열의 요소를 강제 타입 변환을 수행하여 복사할 수 있다.
- 값 타입 요소를 박싱하여 참조 타입 요소로 변환하는 작업
- 참조 타입 요소를 값 타입 요소로 언박싱하여 변환하는 작업
- CLR 기본값 타입을 확장하는 작업
- 타입 간에 서로 호환성이 없을 경우 다운캐스팅
간혹 배열을 특정 타입에서 다른 타입으로 변환하는 것이 매우 유용할 때가 있다. 이러한 기능을 배열 공변성(Array Covariance) 이라고 부르는데, 이를 활용하면 편리하긴 하지만 그에 대비되는 성능상의 불이익이 있으므로 이를 반드시 이해하고 있어야 한다.
만약 배열의 요소에 대한 복사본을 만드는 일만 하고 싶다면, System.Buffer.BlockCopy 메서드를 사용하는 것이 System.Array.Copy 메서드보다 더 빠르다.하지만 BlockCopy는 기본 타입에 대해서만 사용할 수 있으며 타입 캐스팅을 지원하지 않는다. 이는 보통 바이트 배열을 복사할 때 사용한다.
만약 배열의 요소들을 한쪽 배열에서 다른 쪽 배열로 안전하게 복사하기를 원한다면, 반드시 System.Array.ConstrainedCopy 메서드를 사용해야 한다. 이 메서드는 복사 작업이 완벽하게 끝낼 수 있도록 해주어, 복사 작업이 실패할 경우에도 대상 배열의 데이터를 변경하거나 훼손하지 않는다. 하지만 이 메서드를 사용하려면 대상 배열의 요소 타입이 원본 배열의 요소 타입과 같거나 업 캐스팅만 가능하다. 더 나아가서, 이 메서드는 박싱, 언박싱, 다운캐스팅을 허용하지 않는다.
모든 배열이 암묵적으로 구현하는 IEnumerable, ICollection, IList 인터페이스
CLR팀은 System.Array 타입에 IEnumerable, ICollection, IList 인터페이스를 직접 군현하도록 하지 않았는데, 이는 다차원 배열과 시작 인덱스가 0이 아닌 배열들에 지원해야 하는 문제 때문이었다. 하지만 이 인터페이스들을 System.Array에서 구현할 수만 있다면, 모든 배열 타입에 대해서 이 인터페이스들을 활용할 수 있었을 것이다. 이러한 이유로 CLR은 약간의 트릭을 이용하여, 1차원 배열이면서 시작 인덱스가 0인 배열 타입이 만들어지면 IEnumerable, ICollection, IList 인터페이스를 자동으로 구현하고, T가 참조 타입인 경우 상속 계통을 따라 모든 상위 클래스들에 대해서도 추가적으로 세 개의 인터페이스를 구현한다. 예를 들어 FileStream으로 배열을 만들면, FileStream의 상위 계층인 Stream과 Object에 대한 3가지 인터페이스를 자동으로 구현한다.
FileStream[] fsArray; // fsArray는 다음의 메서드들의 매개변수로 사용할 수 있다.. void M1 (IList<FileStream> fsList) { ... } void M2 (ICollection<Stream> sCollection) { ... } void M3 (IEnumerable<Object> oEnumerable) { ... }주의해야 할 것은 값 타입의 요소를 가지는 배열의 경우, CLR이 자동으로 배열 요소의 상위 타입에 대한 인터페이스를 구현해주지 않는다는 점이다.
배열의 전달과 반환
메서드의 매개변수로 배열을 전달할 때에는 실제로는 배열의 참조를 전달하게 된다. 따라서 호출되는 메서드는 내부에서 배열의 요소를 수정할 수 있다. 이를 방지하려면, 반드시 배열의 사본을 만든 후 그 사본을 메서드에 전달해야 한다. 여기서 주의해야 할 것은 Array.Copy 메서드는 얕은 복사를 수행하므로, 만약 배열의 요소가 참조 타입인 경우 새로운 배열 사본을 만들었다고 하더라도 여전히 옛 배열 요소들을 참조하게 된다.
만약 배열에 대한 참조를 반환하는 메서드를 정의하는 경우, 이 배열이 원소를 하나도 가지지 않는다면 null을 반환하거나 빈 배열을 반환할 수 있다. 이러 경우 대체로 null을 반환하기 보다는 빈 배열을 반환하는 것이 좋다. 이는 만약 배열을 조회할 때 빈 배열이면 문제가 없지만 null이 들어 있다면 따로 예외 처리를 해줘야 하기 때문이다.
Appointment[] appointments = GetAppointmentsForToday(); if(appointments != null){ // if 부분을 생략할 수 있다! for (int a = 0; a < appointments1.Length; a++){ ... } }배열의 내부 구조
내부적으로 CLR은 두 가지 유형의 배열을 지원한다.
- 1차원 배열이면서 시작 인덱스가 0인 배열, 이러한 배열을 SZ 배열 또는 벡터라고 한다.
- 시작 인덱스가 지정되지 않은 1차원 배열과 다차원 배열
실제 코드상에서 서로 다른 종류의 배열들의 타입을 출력해보면 다음과 같다.
- 시작 인덱스가 0인 1차원 배열 = System.String[]
- 시작 인덱스가 0이 아닌 1차원 배열 = System.String[*]
- 시작 인덱스 관계없이 다차원 배열 = System.String[,]
시작 인덱스가 0인 1차원 배열에 대한 요소에 대한 접근은 다른 배열들 보다 빠르다. 여기에는 몇 가지 이유가 있는데 첫째로 전용의 newarr, ldelem, ldelema, ldlen, stelem과 같은 IL 명령들이 있어서, 이들을 이용하는 코드를 생성할 수 있기 때문이다. 둘째로 대부분의 경우에 JIT 컴파일러는 인덱스 범위를 확인하는 코드를 반목문 바깥쪽에 생성하여 단 한 번만 수행되도록 할 수 있기 때문이다.
예를 들어, 반복문에 array의 Length속성을 사용한다면 속성을 조회해 임시 변수에 저장해 결과적으로 속성을 한 번만 조회하게 된다. 또한 배열에 대한 접근이 유효 범위 안에서 이루어지는지 반복문 진입 전에 확인한다.
안타깝게도 이런 기능들은 시작 인덱스가 0인 1차원 배열에 대해서만 적용된다. 따라서 성능이 매우 중요한 경우라면, 사각 형태의 다차원 배열 대신 배열 내에 또 다른 배열을 포함하는 중첩 배열의 사용을 고려해보는 것이 좋을 수 있다.
안전하지 않은 배열 사용과 고정 크기 배열
성능이 정말 매우 중요한 경우라면, 관리되는 배열 객체를 힙상에 만들지 않고 C#의 stackalloc문을 사용하여 스레드의 스택 영역상에 만드는 것이 가능하다. stackalloc문을 사용할 때에는 1차원이고 시작 인덱스가 0인 값 타입의 배열만을 만들 수 있고, 값 타입은 참조 타입을 포함해서는 안 된다. 이렇게 만들어진 배열은 안전하지 않은 포인터를 이용해서 접근할 수 있는 일련의 메모리 블록으로 생각하는 것이 좋다. 이런 이유로 이 메모리 버퍼의 주소를 바로 전달할 수 있는 FCL 메서드는 거의 없다. 물론 이처럼 스택상에 할당된 메모리는 메서드가 반환되면 즉시 자동으로 소거되므로 성능 향상을 꾀할 수 있다. 이 기능을 사용하기 위해서는 C# 컴파일러에 /unsafe 스위치를 지정해야 한다.
보통 배열은 참조 타입이기 때문에, 구조체 내에 필드로 배열을 두는 경우에도 배열에 대한 포인터나 참조 값만 구조체 내에 존재하고, 배열 그 자체는 구조체의 메모리 외부에 존재하게 된다. 하지만 구조체 내에 직접 배열을 포함시키는 것도 가능하다. 구조체 내에 배열을 직접 포함시키려면 다음의 요구사항을 만족해야 한다.
- 배열의 타입은 반드시 값 타입이어야 하며, 배열 내부에 참조 타입을 포함해서는 안된다.
- 필드 그 자체 또는 그 필드를 포함하는 구조체는 반드시 unsafe 키워드를 추가해야 한다.
- 배열 필드는 반드시 fixed 키워드로 표시되어야 한다.
- 배열은 반드시 1차원이면서 시작 인덱스가 0이어야 한다.
'CLR Via C#' 카테고리의 다른 글
18장. 사용자 정의 특성 (0) 2022.03.16 17장. 델리게이트 (0) 2022.03.09 15장. 열거 타입과 비트 플래그 (0) 2022.02.23 14장. 문자, 문자열, 텍스트 사용하기 (0) 2022.02.21 13장. 인터페이스 (0) 2022.02.16