상수와 변경할 수 없는 이름 (const와 final) 용법
프로그램 안에서 쓰이는 이름 선언에서 변경될 수 없는 객체로 선언되는 것들이 있다. 자바의 final 키워드와 C/C++의 const 키워드가 그런 선언을 위한 키워드다. 그런데 이 키워드의 사용이 생각보다 까다롭다. 그리고 변경할 수 없는 이름 중에는 컴파일러에 의해 상수로 처리되는 경우도 있다. 이들의 응용법과 차이를 이해하는 것은 프로그래밍 언어에서 중요한 주제다.
먼저 이런 값을 변경할 수 없는 변수 이름이 어떤 경우에 쓰이는지 생각해 보자. 값을 저장하기 위해 사용하는 것이 변수인데 값을 변경할 수 없게 선언하는 이유는 무엇인가? 우리는 일상 생활에서도 정해진 값을 사용하는 경우가 많다. 지하철 역 번호나 버스 정류장 번호 등은 아마도 한번 정해지면 바뀌지 않을 것이다. 온라인쇼핑몰이라면 쇼핑몰의 이름, 사이트 주소, 사업자 번호 등은 변경되지 않을 것들이다. 이런 값은 어딘가에 저장되어 있고 프로그램 코드에서 이름으로 사용되지만 값이 바뀌지 않을 객체들이다. 물론 이런 값이 변경되거나 사이트가 폐쇄되거나 하는 일이 생길 수 있지만 프로그램 코드 내에서는 그런 이름들이 바뀌는 경우는 고려하지 않는다.
변경되지 않는 이름은 컴파일러가 코드를 효율적으로 구현할 수 있게 해 준다. 그런 변수는 load나 store 등이 발생하지 않아도 됨을 알려준다. 그 변수를 포함한 수식은 값이 변경되지 않을 것이므로 한번만 계산해도 된다. 다른 함수에 넘길 때도 값이 변경되었나 확인할 필요도 없다. 즉 컴파일러 입장에서 맘 편하게 다룰 수 있는 객체가 된다. 또한 final 변수는 코드의 가독성에도 도움이 된다. 어떤 값이 변경되지 않아야 함을 알면 코드를 읽을 때 그 이름의 역할이 훨씬 분명해지고 수식이나 코드 부분의 의미도 명료해 진다.
자바의 final 키워드는 값이 변경되지 않는 이름을 선언하게 해준다. final 키워드는 변수, 메소드, 클래스 등에 모두 사용될 수 있으나 여기서는 변수 이름 선언에만 한정하여 살펴본다.
final 키워드는 static 필드, 일반 필드, 지역변수 선언에 사용될 수 있다. final 키워드의 의미는 그 이름에 바인딩된 객체(메모리 영역)의 값은 처음 초기화된 이후에는 변경될 수 없음을 뜻한다. 선언하면서 바로 final int a = 0; 이런 식으로 지정하면 선언 초기화라고 하고 final int a;로 선언된 후 처음 사용되기 전에 a = 0; 이렇게 초기화하는 것도 가능하다. 이렇게 초기화 된 이후에는 그 이름이 지정문의 좌변에 나올 수 없고 ++ 같은 값이 변경되는 연산이 적용될 수도 없다.
필드의 경우 final이 아닌 변수 이름은 초기화하지 않으면 디폴트로 0으로 초기화된다. (지역변수는 디폴트 초기화가 없다) 그러나 fial 키워드가 붙은 선언은 반드시 명시적으로 초기화해야 한다. 일반 필드는 선언초기화를 하지 않으면 생성자에서 초기화해야 한다. (static 필드는 static 초기화 블록에서 초기화되어야 한다.) 지역변수는 선언 초기화하든가 아니면 그 함수에서 처음 사용하기 전에 어딘가에서 반드시 초기화가 되어야 한다. (사실 자바의 지역변수는 final 이 아니어도 사용되기 전에 반드시 초기화되어야 한다.) 그렇게 초기화된 후에는 그 이름에 대해 지정이나 변경이 발생하면 컴파일 오류가 난다. 즉 final 성질은 컴파일러가 그 이름이 변경 연산의 대상이 되지 못하게 막아주는 것이다.
final 이름의 초기화는 입력일 수도 있고 난수 생성이 될 수도 있다. new에 의한 동적할당이 될 수도 있다. 즉 어떤 값으로 초기화될지 컴파일러는 알지 못한다. 이런 경우는 그 이름에 해당하는 메모리가 할당되고(객체생성 및 바인딩생성) 초기화 값이 그 메모리에 저장되어야 한다. 이것을 실현시간 상수라고도 한다.
그런가 하면 어떤 변경되지 않는 이름의 값을 컴파일러가 알 수 있는 상수가 있다. 그 경우 아예 이름 자체가 필요하지 않고 C 언어의 #define 매크로의 용법처럼 아예 코드에서 이름이 값으로 바뀌는 것으로 처리될 수도 있다. 그러면 그 변수에 메모리를 할당할 필요가 없고 그 이름을 상수가 쓰여야 하는 자리에 쓸 수도 있다. 상수가 쓰여야 되는 예를 들면 C 언어에서는 선언되는 배열의 크기를 나타내는 식이나 switch 문에서 case 자리에 나올 수 있는 이름이 된다.
자바 언어에서 final 상수가 컴파일시간 상수로 초기화된 경우, 즉 컴파일러가 그 값을 알면 #define처럼 이름 대신 값을 바꿔넣는 효과가 나게 된다.
final int MAX_COUNT = 100;
for (int i = 0; i < MAX_COUNT; i++) { ... }
이런 식의 상수 이름의 사용은 코드의 가독성과 유지보수를 위해서 많은 도움이 된다. 그리고 컴파일러는 효율적인 코드를 생성할 수 있다. 여기서 효율적인 구현이란 무엇일까? 이 경우 MAX_COUNT라는 이름에 대해 메모리가 할당될 필요가 없다. 즉 객체가 생성되지 않고 바인딩도 생성되지 않는다. 그냥 코드 상의 그 이름이 100으로 바뀌는 것과 같은 효과가 된다.
for (int i = 0; i < 100; i++) { ... }
이것은 매크로의 #define과 같은 효과지만 차이가 있다. C 매크로는 전처리 단계에서 바꿔치기해 버리므로 컴파일러는 그 대상을 이름으로 구별할 수 없고 이름의 범위도 알 수 없다. (C에서는 파일마다 똑같은 #define을 반복해야 한다) 자바의 final은 컴파일러에 의해 타입 검사와 이름의 범위 처리 및 프로그램 전체에서 이름으로 대상을 가리킬 수 있고 접근자(private이나 public이냐)도 구분할 수 있다. 이러한 상수 이름을 컴파일 시간 상수(compile time constant)라고 한다.
final은 변경할 수 없는 변수 또는 컴파일 시간 상수 두 가지 경우로 사용된다. 그리고 두 경우 모두 코드의 가독성과 효율성이 동시에 좋아진다. 프로그래밍 언어에서 가독성과 효율성은 서로 트레이드오프 관계인 경우가 많은데 두 가지가 동시에 좋아지는 것은 두 마리 토끼를 잡는 것과 같다. 그러므로 프로그래밍에서 아주 바람직한 기능이라고 할 수 있고 가능한 한 자주 써주어야 한다.
컴파일 시간 상수가 프로그램에서 하는 역할이 크기 때문에 Rust는 const 키워드로 이러한 컴파일시간 상수를 선언할 수 있게 한다. 컴파일 시간 상수의 중요성을 높이 인정한 것이라고 볼 수 있다.
다음으로 C 언어의 const에 대해 살펴보자. C 언어는 포인터 때문에 자바의 final보다 훨씬 복잡한 const 용법을 가지고 있다. 그런가하면 const 선언은 반드시 선언 초기화되어야 한다는 점에서 자바와 다르다.
C 언어에서도 const int *iptr = &n; 이렇게 선언하면 iptr는 그 주소가 가리키는 값을 변경할 수 없는 변수가 된다.
int n = 10;
const int *iptr = &n;
(*iptr)++; // error, iptr가 가리키는 값은 변경할 수 없음
n++; // ok. 변수 n은 변경 가능한 변수임
(이전의 C 언어 const에 대한 부분은 최근 C 언어에서 제외되었습니다)