본문

C의 기억부류(Storage Class)와 변수 #3. 타입 한정자(Type Qualifier)

새로운 제목으로 글을쓰려다 그냥 3번째 연재글로 올리기로 했다. 앞의 두 글은 기억부류를 지정하는 auto, extern, static, register의 4가지 지정자(Specifier)에 대해 다루었다. 변수의 특성을 결정하는데에는 데이터 타입(데이터형)과 기억부류가 있는데, C90에서는 여기에 불변성(constancy)과 휘발성(volatility)이라는 특성이 추가되었다. 이 둘을 위하여 const, volatile 라는 타입 한정자(Type Qualifier)가 사용되며, 이 키워드로 변수를 한정된 데이터형(qualified type)으로 만들 수 있다. 또한 추가로, C99에서는 컴파일러 최적화를 위해 세번째 한정자로 restrict를 추가하였다. 즉 const, volatile, restrict 세가지 타입한정자가 존재한다. 


책에서는 데이터형 한정자(Data Qualifier)라는 표현을 사용했는데, 타입 한정자라는 단어가 더욱 익숙하기 떄문에 타입 한정자라는 용어를 사용하기로 한다. 한정자/Qualifier라는 의미가 명확하게 와닿지 않아서 영어사전을 확인해보니 예선통과자라는 뜻이 가장 먼저 나오는데 이건 아닌것같고, 같이 검색된 프랑스어 뜻을 참고하면 '형언/수식' 의 의미인듯하다. 이 뜻을 반영해보자면 '데이터에 부가적인 정보를 제공하기 위한' 키워드, 정도가 되지않을까 싶다.


* Const 타입한정자

변수선언에서 const 키워드는 해당 변수를 대입/증가/감소 연산으로 값을 변경할 수 없는 변수로 만든다, 따라서 선언시에만 값 초기화가 가능하다. const 키워드를 일반 변수 선언에 사용할때에는 크게 걱정할것은 없지만, 포인터변수 선언시에는 위치에 따라서 그 의미가 달라지므로 주의해야한다. 즉, 포인터 '주소' 자체를 const로 만드는것과, 포인터가 가리키는 '값'을 const로 만드는 상황을 구분해야 한다. 예를 들어 다음과 같은 코드를 생각할 수 있다.


1. const float * pf;

- pf는 항상 상수로 유지해야하는 float형 '값'을 가리키는 포인터변수다. pf가 가리키는 주소는 변경될 수 있다. 즉, pf가 다른 const 값을 가리키도록 pf의 주소를 변경할 수는 있다. (*pf)++ 불가능

2. float * const pt;

- 포인터 pt의 자체의 값(주소)을 변경할 수 없다. 즉, pt는 항상 같은 주소를 가리킨다. 그러나 pt가 가리키는 값은 변경할 수 있다. 포인터 변수 pt의 주소를 상수화 시킨다는 의미이다. (*pt)++; 가능

3. const float * const ptr;

- ptr은 항상 같은 주소를 가리키고 그 위치에 저장된 값도 변경할 수 없다.

4. float const * pfc;

- const float * pfc;와 같다. 데이터형 이름과 * 사이에 const를 넣는것은 그 포인터를 사용하여 그것이 가리키는 값을 변경할 수 없다는 것을 의미한다. 즉, const를 * 왼쪽 어딘가에 넣으면 그 '값'을 상수로 만들고 *의 오른쪽에 넣으면 그 포인터 자체('주소')를 상수로 만든다.


함수 선언에서의 const의 의미또한 알아둘 필요가 있다. 1. 파라미터로 넘어간 변수의 값을 변경할 수 없다, 2. 리턴된 '값'은 변경할 수 없다. 라는 두가지의 의미와 함께, C++에 추가된 클래스 개념을 위해 3. 클래스 멤버변수값을 변경할 수 없다. 의 세가지의 의미가 있다. 클래스 멤버 변수에도 const를 붙일 수 있는등, 클래스에서 const 사용 시 고려할점이 더 있는데 이와 관련해서는 이 글('[C++ 언어] 제 6 강 : static, const 맴버') 이 참고하기 좋아보인다.


1. void display(const int array[])

 - display 함수는 호출함수에 있는 '데이터'를 변경할 수 없게된다.

2. const int* f() { return &i; }

- f()가 반환하는 값은 반드시 const int* 변수에 대입되어야 한다. 이것은 f()의 반환 값이 추후에라도 변경되는것을 방지한다.

3. void CTest::Print() const;

- 멤버함수의 괄호 뒤에 const를 붙이면 해당 함수는 클래스(CTest) 내의 어떠한 멤버변수도 값을 변경시킬 수 없다. (포인터형을 리턴할수도 없다)


또한 프로그램에서 사용할 상수를 선언할 떄, 전역변수를 사용하다보면 실수로 데이터가 변경될 수 있으므로 이를 방지하기위해 const를 사용하기도 한다. 상수를 선언하는데에는 두가지 방법이 있는데, 1. 전역변수형태로 const double PI=3.14; 로 하고 외부 파일에서 extern const double PI;를 사용할수도 있고, 아니면 2. 헤더 파일에 static const double PI = 3.14;로 선언하고 c 파일에서 헤더파일을 include하여 사용할 수 있다. 헤더파일방식으로 상수변수를 선언했을때의 장점은 한 파일에 전역변수를 선언하고 나머지 파일들에 extern으로 참조선언을 추가로 해줄 필요가 없이 그저 모든 파일들이 동일한 헤더파일을 include하면 된다는것이다. 대신 단점으로 데이터가 중복되어 저장된다는 점이 있다.


전역변수의 형태로 헤더파일에 상수를 정의할때, 만약 static을 사용하지않은 채(즉, double PI) c파일에서 include하는경우, 동일한 변수의 정의선언이 각 파일에 들어가는 결과를 가져온다. 즉, 각 변수들이 '외부연계 정적변수'로 사용되어 별개의 데이터 복사본이 각각의 파일에 하나씩 제공된다. 따라서 이름이 동일하더라도 전혀 다른 변수로 인식되고 사용되기 때문에 파일간의 커뮤니케이션에 그 데이터를 사용하면 제대로 동작하지 않을 수 있다. 그러나 const라도 사용하게되면(즉, const double PI) 해당 변수의 값이 변하지 않으므로 조금 더 안정적인 실행을 기대할 수 있다(물론 static const 둘다 사용하는것이 최선이다). 


* Volatile 타입한정자
volatile 한정자는 어떤 변수가 현재 동작하는 프로그램이 아닌 다른 엔티티에 의해 그 값이 변경될 수도 있다고 컴파일러에게 알린다. 일반적으로 하드웨어 주소, 또는 동시에 실행되는 여러 프로그램들이 공유하는 데이터에 사용된다. 이는 컴파일러의 최적화를 지원하기 위해 새로 생성된 키워드이다.

Val1 = x;
/* 변수 x를 사용하지않는 코드부분 */
Val2 = x;

위와 같은 코드가 있을때 최적화된 컴파일러는 x의 값이 변경되지않고 두번 사용되고 있다는것을 눈치채고 값 x를 레지스터에 임시로 저장하고나서, val2를 위해 x를 읽어야할때 원래 메모리위치 대신에 레지스터에서 그 값을 읽어서 시간을 절약한다. 이것을 캐싱(caching)이라 한다. 일반적으로 캐싱은 두 명령문 사이에서 다른 엔티티에 의해 x가 변경되지 않을때만 좋은 최적화이다. volatile 키워드가 없었던 옛날에는 외부에 의해 x의 값이 변경되었다는 사실을 컴파일러가 알수가 없기 함부로 캐싱을 시도할 수 없었다. 하지만 volatile 키워드가 정의된 이후부터는, volatile이 사용되지 않으면 해당 변수는 외부에 의해 변하지 않는 변수라고 가정할 수 있기때문에 캐싱을 적용할 수 있다.
하나의 값은 const 이면서 동시에 volatile일수있다. 예를들어 하드웨어 클록설정은 현재 프로그램에 의해 변경되면 안되므로 const로 만든다. 하지만 프로그램이 아닌 다른 엔티티에 의해 변경되므로 volatile로 만든다. (volatile const int loc; const volatile int * ploc;)

주의할점은 C에서의 volatile 키워드는 컴파일러의 최적화를 위하는것이지 다른 언어에서 사용되는 volatile 키워드와는 의미가 다르다는 것이다. 대개 멀티쓰레드 프로그래밍을할때 데이터가 실시간적으로 반영되는것을 보장하기 위해서 volatile 키워드를 사용하는데 C/C++에서는 그런 쓰레드간 실행순서의 차이를 극복하기 위한 수단이 아닌, 블럭 내에서의 코드최적화를 위한 단순한 수단이라는것을 기억할 필요가 있다. 관련해서는 오래전에 작성한 'synchronized, volatile : Race Condition을 위한 Java의 Keywords' 글을 참고할 수 있다.

* Restrict 타입한정자
restrict 키워드는 컴파일러가 특정 유형의 코드를 최적화할 수 있도록 허용하여 계산능력을 향상시킨다. restrict 키워드는 포인터에만 적용할 수 있다. 그것은 그 포인터가 어떤 데이터 객체에 접근하는 유일한 최초수단이라는 것을 나타낸다. '유일한'이라는 의미는, 다른 포인터변수가 해당 데이터를 가리키지 않는다는것을 명시적으로 나타낸다. 이를 통하여 해당 변수에서 연산이 여러번 분산되어 이뤄지는경우 컴파일러는 이를 최적화하여 한번의 연산으로 변형한다. restrict 키워드가 없다면 컴파일러는 포인터의 두번에 걸친 사용사이에 어떤 다른 식별자가 그 데이터를 변경했을 수 있다고 가정해야 한다. 그러나 restrict 키워드를 사용하면 컴파일러는 더 빠른방법을 찾을 수 있다. 

int * restrict restar = malloc(10);
int ar[10];
int * par = ar;
restar[0] += 1;
restar[0] += 5;
=> restar[0] += 6으로 치환된다.

ar[0] +=1;
par[0] += 2
ar[0] += 5;
=> ar[0] += 6 으로 치환되면 좋겠지만 해당 포인터를 가리키는 다른 변수 par를 통해 값이 변경되었으므로(될 수 있으므로) 치환할 수 없다. 

restrict 키워드가 함수 매개변수로 사용되면 이것은 함수의 몸체 안에서 그 포인터가 가리키는 데이터를 다른 식별자로 수정할 수 없다는것과, 다른방법으로는 불가능한 최적화를 컴파일러가 시도할 수 있다는것을 의미한다. 예를들어 void * memcpy(void * restrict s1, const void * restrict s2, size_t n); 에서는 각 포인터가 그것이 가리키는 위치에 접근할 수 있는 유일한 수단이라는것을 의미한다. 그래서 이 두 포인터는 동일한 데이터 블록에 접근할 수 없다는것이 논리적으로 보장된다.

내용출처: Stephen Prata, 'C 기초플러스(C Primer Plus)' 12장.

댓글

Holic Spirit :: Tistory Edition

design by tokiidesu. powerd by kakao.