선요약
- Array.Copy로 값타입의 배열도 캐스팅 할 수 있다.
- 모든 배열은 암묵적으로 IEnumerable, ICollection, IList를 구현한다. 또한 IEnumerable<T>, ICollection<T>, IList<T>도 구현하며 참조 타입의 경우 T의 상위 타입까지 구현한다. (값 타입은 T만)
- 데이터가 없는 배열을 반환하거나 필드에 정의하는 경우에는 null보다는 빈 배열을 참조하도록 하는게 좋다. (프로그래머의 예외처리를 줄여준다.)
- 다차원 배열보다는 중첩 배열이나 1차원 배열이 더 빠르다.
배열
- 모든 배열은 System.Array 타입을 상속한다.
- CLR에서는 1차원 배열, 다차원 배열, 중첩 배열을 지원한다.
int[,] darr = new int[5, 10]; //다차원 배열
int[][] jarr = new int[2][]; //중첩 배열
jarr[0] = new int[5];
jarr[1] = new int[10];
- 모든 배열은 참조 타입이다.
- CLR 표준 규약에서 모든 배열은 항상 시작 인덱스가 0이어야 한다. 그러나 아니게도 할 수 있다. (Array.CreateInstance 를 이용) - 이건 지금으로썬 딱히 쓸 일이 없어 보여서 안 다룸.
- 값 타입 배열과 참조 타입 배열의 메모리 배열은 다음과 같다.
int[] valTypeArr = new int[100];
object[] refTypeArr = new object[100];
배열 캐스팅
CLR은 참조 타입 요소로 구성된 배열의 암묵적 캐스팅을 허용하지만 값 타입은 그렇지 않다.
string[,] str2dim = new string[100, 100];
object[,] obj2dim = str2dim; // OK!
int[,] int2dim = new int[100, 100];
float[,] float2dim = (float[,])int2dim; // 암묵적, 명시적 둘 다 불가!
그러나 값 타입도 Array.Copy를 이용하면 가능하다.
int[] intArr = new int[10];
float[] floatArr = new float[intArr.Length];
Array.Copy(intArr, floatArr, intArr.Length); //OK
Copy 메서드는 다음과 같은 변환을 지원한다.
- 값 -> 참조
- 참조 -> 값
- 기본 값 타입의 확장 (e.g. int[] -> double[])
- 타입 간 호환성이 없을 경우 다운캐스팅. (e.g. object[] -> IComparable[], 모든 배열의 요소가 IComparable을 구현한 경우)
Array.Copy 메서드는 C의 memmove같이 메모리상에서 복사 영역이 겹쳐도 정확히 복사를 해준다.
다음과 같이 Copy메서드를 활용할 수 있다.
class MyClass : IComparable
{
public int CompareTo(object obj)
{
...
}
}
public class Program
{
public static void Main()
{
MyClass[] src = new MyClass[10];
IComparable[] dest = new IComparable[src.Length];
Array.Copy(src, dest, src.Length);
}
}
또한 다음과 같이 캐스팅을 한 경우 타입을 체크하느라 성능이 저하될 수 있다. (실행 시점에 타입 안정성 보장 x)
string[] strArr = new string[10];
object[] objArr = strArr;
objArr[3] = "YANG"; //성능 저하 : CLR이 objArr의 요소가 string인지 확인
objArr[0] = 1; //성능 저하 : CLR이 objArr의 요소가 string인지 확인 + 예외 발생
System.Buffer의 BlockCopy메서드를 이용하면 기본 타입에 대해서 Array.Copy보다 더 빨리 배열의 요소에 대한 복사본을 만들 수 있다.
public static void BlockCopy(Array src, int srcOffset, Array dst, int dstOffset, int count);
이 때 offset은 인덱스를 의미하는 것이 아니라 배열 내의 바이트 오프셋이다.
모든 배열이 암묵적으로 구현하는 IEnumerable, ICollection, IList
System.Array는 IEnumerable, ICollection, IList를 구현한다. CLR팀은 System.Array가 제네릭 버전의 인터페이스들 구현하지 않도록 했는데, 다차원 배열과 시작 인덱스가 0이 아닌 배열들의 지원에 대한 문제 때문이다. 따라서 CLR은 1차원 배열이면서 시작 인덱스가 0인 배열에 한해 한정적으로 IEnumerable<T>, ICollection<T>, IList<T> 를 구현한다. T가 참조 타입인 경우 상속 계통을 따라 모든 상위 클래스에 대해서도 인터페이스를 구현한다.
즉, FileStream[] 타입을 생성한다면
IEnumerable<object>, ICollection<object>, IList<object>
IEnumerable<Stream>, ICollection<Stream>, IList<Stream>
IEnumerable<FileStream>, ICollection<FileStream>, IList<FileStream>
을 모두 구현한다. (CLR에 의해)
단, T가 값 타입이라면 T에 대해서만 구현한다. (배열의 메모리 레이아웃이 다르기 때문이다.)
배열의 전달과 반환
메서드의 매개변수로 배열을 전달하거나 배열을 반환할 때에는 배열의 참조를 전달하는 것이다. 따라서 호출되는 메서드는 내부에서 배열의 요소를 수정할 수 있다. 이를 방지하려면, 반드시 배열의 사본을 만든 후 그 사본을 메서드에 전달해야 한다. Array.Copy 메서드는 얕은 복사를 수행하므로, 요소가 참조 타입인 경우는 배열을 복사해도 여전히 옛 배열 요소를 참조한다.
만약 배열에 대한 참조를 반환하는 메서드를 정의하는 경우에는 null이나 빈 배열을 반환할 수 있을 것이다. 마이크로소프트는 이 경우 null보다는 빈 배열을 반환하기를 강력히 권고한다. 이렇게 하면 예외처리를 최소화할 수 있기 때문이다. 마찬가지로 반환 뿐 아니라 배열에 대한 참조를 가지는 경우, 데이터가 없다고 하더라도 null보다는 빈 배열을 가지는 것이 좋다.
배열 내부 구조
1차원의 시작 인덱스가 0인 배열의 요소에 접근하는 것은, 시작 인덱스가 0이 아닌 배열이나 다차원 배열에 접근하는 것 보다 좀 더 빠르다.
첫째로 1차원의 시작 인덱스가 0인 배열 전용의 newarr, ldelem, ldelema, ldlen, stelem 같은 IL 명령어들을 사용하는 코드를 사용할 수 있기 때문이다.
둘째로 대부분의 경우에 JIT 컴파일러는 인덱스 범위를 확인하는 코드를 반복문 바깥쪽에 생성하여 단 한 번만 수행하도록 한다.
int sum = 0;
int[] arr = new int[5];
for (int i = 0; i<arr.Length; ++i)
sum += arr[i];
위와 같은 코드에서 for문의 반복 여부를 활용할 때, 배열의 Length 속성을 사용한다. (실제로는 메서드 호출이겠지) 따라서 매 반복 여부를 체크할 때 마다 Length를 호출하는 것은 비효율 적일 것이다. JIT컴파일러는 Length가 Array클래스의 속성임을 알고, 임시 변수를 마련하여 조회한 속성 값을 저장한다음 반복문의 반복 여부에 사용하도록 한다. 그 결과 코드는 좀 더 빨라지게 된다. 몇몇 개발자들은 임의로 이런 일들을 하는 코드를 작성하는데 이런 어설픈 최적화는 거의 대부분 성능에 부정적 영향을 끼치고 가독성을 떨어트린다. 따라서 그냥 Length 써서 작성해라.
또한 JIT컴파일러는 for문의 조건에서 i가 arr.Length - 1까지 접근하려 한다는 사실을 알고 있다. 그래서 JIT 컴파일러는 실제 실행 시에 배열에 대한 접근이 유효 범위 안에서 이뤄지는지 확인한다. 즉,
if(( 0 >= a.GetLowerBound(0)) && ((Length - 1) <= a.GetUpperBound(0))
위와 같은 의미의 코드를 작성하여 반복문 진입 직전에 실행한다. 만약 위의 조건에 통과하면 반복문 내부에서 각 배열 요소에 대한 접근이 올바르게 이뤄지고 있는지 검사하는 코드를 추가하지 않는다.
시작 인덱스가 0이 아닌거나 다차원 배열에 대한 접근은 1차원이면서 시작 인덱스가 0인 배열의 경우보다 성능이 훨씬 나쁘다. 왜냐면 JIT 컴파이러가 이러한 유형의 배열들에 대해서 반복문에 대한 인덱스 확인을 간소화 하지 않으며, 따라서 각각의 배열 요소에 대한 접근이 일어날 때마다 매번 인덱스 유효성을 검사한다. 이 외에도 JIT 컴파일러는 현재 인덱스로부터 배열의 시작 인덱스를 빼서 오프셋을 계산해야 하는데, 이 또한 코드를 더 느리게 만든다. (시작 인덱스가 0인 경우 뿐 아니라 다차원 배열도 포함)
따라서 성능이 매우 중요한 경우라면, 다차원 배열 대신 중첩 배열의 사용하는 것을 고려해보자.
뿐만 아니라 안전하지 않은 코드에서 배열을 사용하는 경우에도 이런 인덱스 유효성 검사를 건너 뛰어 성능 향상을 꾀할 수 있다. (안전하지 않은 배열은 byte,int,double..등의 기본 값타입이나 구조체 열거타입만 쓸 수 있음) 그러나 이런 기능은 메모리에 직접 접근을 가능하게 하므로, 배열 범위 밖에 있는 메모리에 접근하면 예외가 발생하지 않음에도 메모리를 훼손할 수 있고, 타입 안정성을 깨트리며, 보안상의 취약점으로 발전할 수 있기에 매우 깊은 주의를 요한다.
private static unsafe int UnsafeSum(int[,] pArr)
{
int sum = 0;
fixed (int* ptr = pArr)
{
for (int i = 0; i < c_numElements; i++)
for (int j = 0; j < c_numElements; ++j)
sum += ptr[i * c_numElements + j];
}
return sum;
}
이 외에도
private static void StackAlloc()
{
unsafe
{
const int width = 4;
char* ptr = stackalloc char[width];
string s = "YANG";
for(int i=0;i<width;++i)
{
ptr[i] = s[width - i - 1];
}
Console.WriteLine(new string(pc, 0, width)); //GNAY 출력
}
}
stackalloc 이란 것도 있다. 알아만 두고 나중에 쓸 때 자세히 찾아보자.
'언어 > C#' 카테고리의 다른 글
C# 사용자 정의 특성 (0) | 2021.02.28 |
---|---|
C# - 델리게이트 (1) | 2021.02.21 |
C# - 문자열 - 1 (0) | 2021.02.02 |
C# 인터페이스 (0) | 2021.01.27 |
C# 제너릭 - 3 (0) | 2021.01.20 |