프로그래밍을 하다 보면 대부분의 언어들이 배열 등의 인덱스를 0부터 시작하는데요. 인간의 입장에서는 숫자를 1이 아닌 0부터 세는 것이 부자연스러워 보입니다.
이번 글에서는 컴퓨터가 숫자를 0부터 계산했을 때 생기는 이점에 대해서 알아보겠습니다.
인덱스를 0으로 시작하는 경우와 1로 시작하는 경우를 각각 C#과 Pascal로 구현하여 차이점을 살펴보겠습니다. 대부분의 언어가 0으로부터 인덱스를 시작하는 것과 달리 Pascal은 1부터 시작하는 언어입니다.
0부터 시작하는 인덱스를 사용할 때, 1차원 배열의 인덱스를 2차원 배열의 인덱스로 변환하는 것은 다음과 같이 수행됩니다.
int[] oneDimensional = newint[100];int[,] twoDimensional = newint[10, 10];for (int i = 0; i < 100; i++) {twoDimensional[i / 10, i % 10] = oneDimensional[i];}
2차원 배열의 인덱스를 [x, y]라고 했을 때, y는 1차원 배열의 인덱스가 10이 증가할 때마다 1이 증가됩니다. x의 경우에는 0부터 9까지 반복하게 됩니다.
1차원-i: [ 0] [ 1] [ 2] [ 3] [ 4] [ 5] [ 6] [ 7] [ 8] [ 9] [10]2차원-x: [ 0] [ 1] [ 2] [ 3] [ 4] [ 5] [ 6] [ 7] [ 8] [ 9] [ 0]==> i % 102차원-y: [ 0] [ 0] [ 0] [ 0] [ 0] [ 0] [ 0] [ 0] [ 0] [ 0] [ 1]==> 1 / 10
1부터 시작하는 인덱스를 사용할 때, 1차원 배열의 인덱스를 2차원 배열의 인덱스로 변환하는 것은 약간 더 복잡합니다. 추가적인 연산이 필요하게 되는데요. 아래 Pascal 예제와 함께 살펴보겠습니다.
vari: Integer;oneDimensional: array[1..100] of Integer;twoDimensional: array[1..10, 1..10] of Integer;beginfor i := 1 to 100 dotwoDimensional[(i-1) div 10 + 1, (i-1) mod 10 + 1] := oneDimensional[i];end.
Pascal에서 정수 나누기는 div 연산자를 사용하고, 모듈로는 mod 연산자를 사용합니다. 아래의 도표에서는 편의상 C#과 동일하게 표현하였습니다.
1차원-i: [ 1] [ 2] [ 3] [ 4] [ 5] [ 6] [ 7] [ 8] [ 9] [10] [11]2차원-x: [ 1] [ 2] [ 3] [ 4] [ 5] [ 6] [ 7] [ 8] [ 9] [10] [ 1] ==> (i-1)%10 + 12차원-y: [ 1] [ 1] [ 1] [ 1] [ 1] [ 1] [ 1] [ 1] [ 1] [ 1] [ 2] ==> (i-1)/10 + 1
0부터 시작하는 인덱스를 사용할 때, 2차원 배열의 인덱스를 1차원 배열의 인덱스로 변환하는 것은 다음과 같이 수행됩니다.
int[,] twoDimensional = newint[10, 10];int[] oneDimensional = newint[100];for (int i = 0; i < 10; i++) {for (int j = 0; j < 10; j++) {oneDimensional[i * 10 + j] = twoDimensional[i, j];}}
1부터 시작하는 인덱스를 사용할 때, 2차원 배열의 인덱스를 1차원 배열의 인덱스로 변환하는 것은 다음과 같이 수행됩니다.
vari, j: Integer;twoDimensional: array[1..10, 1..10] ofInteger;oneDimensional: array[1..100] ofInteger;beginfor i := 1to10dofor j := 1to10dooneDimensional[(i-1) * 10 + j] := twoDimensional[i, j];end.
이렇게 예제를 통해 볼 수 있듯이, 1차원 배열과 2차원 배열의 전환은 0-based 인덱싱이 더 간단하고 직관적입니다.
일반적인 상황에서 프로그래머들은 배열의 차원 전환을 신경 써야 하는 경우가 많지 않습니다. 하지만, 프로그램이 운영되는 내부 상황에서는 이러한 연산들이 빈번하게 일어나고 있습니다. 따라서, 0부터 인덱스 시작하는 것이 컴퓨터 입장에서는 더욱 자연스러운 것이 됩니다.
배열이나 포인터의 기준점으로부터 각 요소가 얼마나 떨어져 있는 지를 파악하는 것은 자주 발생하는 연산입니다. 특히 포인터에서는 특정 위치의 데이터에 직접 접근하고 싶을 때가 많습니다. 이러한 경우 offset을 사용하게 되는데요. offset은 기본 주소로부터 얼마나 떨어져 있는 지를 나타내는 값입니다.
여기에서, offset이 0 또는 1로 시작하는 경우를 비교하여 설명하겠습니다.
만약 offset이 1로 시작한다면, 첫 번째 바이트(또는 요소)에 접근하기 위해 -1을 해야 합니다. 이는 offset 값이 실제로 첫 번째 바이트보다 하나 더 앞서기 때문입니다.
char*data = (char*)malloc(1024);// 첫 번째 데이터라는 의미로 1을 사용intoffset=1;// 첫 번째 바이트에 접근하기 위해 offset에서 1을 빼줍니다.char*theFirstByte = data +offset-1;
만약 offset이 0으로 시작한다면, 첫 번째 바이트에 바로 접근할 수 있습니다. 이 경우 추가 연산 없이 포인터가 이미 데이터의 시작점을 가리키고 있기 때문입니다.
char *data = (char *)malloc(1024);// 가리키는 위치를 바로 참조하겠다는 의미int offset = 0;// 첫 번째 바이트에 바로 접근합니다.char *theFirstByte = data + offset;
위의 두 예제를 비교하면, offset이 0인 경우가 훨씬 더 직관적이고 간단하다는 것을 알 수 있습니다. 실제로 많은 프로그래밍 언어와 시스템은 0 기반 인덱싱을 사용하는데, 이는 메모리 주소 연산을 단순화하고 코드의 가독성을 향상시키기 때문입니다.
이번에는 범위를 계산하는 경우에서의 차이를 아래와 같이 2가지의 경우를 각각 살펴보면서 0부터 시작했을 때 1부터 시작했을 때의 차이를 설명드리겠습니다.
(a) 0 ≤ i < 10(b) 1 ≤ i ≤ 10
배열의 첫 번째 원소의 메모리 주소를 기본 주소로 생각하면, 0 ≤ i < 10의 경우, 추가 연산 없이 바로 해당 원소에 접근할 수 있습니다. 1 ≤ i ≤ 10에서는 메모리 주소 계산 시 1을 빼야 하는 추가 연산이 필요합니다.
0 ≤ i < 10의 경우, 배열이나 문자열의 첫 부분부터 특정 위치까지 슬라이싱 할 때, 시작 인덱스를 명시하지 않아도 됩니다. 1 ≤ i ≤ 10에서는 시작 위치 1을 항상 명시해야 합니다.
0 ≤ i < 10과 10 ≤ i < 20처럼, 연속된 범위는 이전 범위의 마지막 값에서 바로 시작합니다. 반면, 1 ≤ i ≤ 10 다음의 연속된 범위는 11 ≤ i ≤ 20이 되어, 시작 값과 끝 값을 모두 조절해야 합니다.
요약하면, 0 ≤ i < 10과 같은 0부터 시작하는 인덱싱은 프로그래밍에서 연산의 간결함, 직관성, 그리고 메모리 접근의 효율성을 제공합니다. 반면, 1 ≤ i ≤ 10과 같은 1부터 시작하는 인덱싱은 추가 연산이나 명시적인 초기화가 필요하며, 연속된 범위 표현이 복잡해질 수 있습니다.