프로그래밍 공부
작성일
2024. 3. 7. 14:43
작성자
WDmil
728x90

10.2 키워드 혼동

const와 static은 C++의 키워드 중에서 혼란을 가장 많이 일으킨다. 두 키워드 모두 몇 가지 서로 다른 의미가 있는데

각 사용 방식에 따른 미묘한 부분을 꼭 이해하고 넘어가야 한다.


10.2.1 const 키워드

const는 constant의 축약형으로 무언가 절대 변경되지 말아야 할 것을 지정한다. const로 표시된 변수는 컴파일러에 의해 모니터링되어 변경 시도 시 에러를 발생시킨다. 더불어서 컴파일러가 코드를 최적화할 때 해당 변수가 변경되지 않는다는 사실을 참조하여 더 나은 효율적인 코드를 만들어낼 수도 있다.

 

const키워드의 사용처는 두 부분으로 나눌 수 있다.

하나는 변수나 파라미터

다른 하나는 메서드다. 


10.2.1.1 const변수와 const파라미터

변경되지 말아야 할 데이터 변수가 있다면 const를 이용해서 보호할 수 있다. const의 중요한 역할 중 하나는 상수값 사용에서 #define을 대체하는 것이다. 이는 가장 자명한 const의 활용처다. 예를 들어 상수값 파이는 다음처럼 선언할 수 있다.

const double PI = 3.141592653589793238462;

 

전역 변수든 클래스 데이터 멤버든 모두 변수는 cosnt로 지정할 수 있다.

 

함수나 메서드의 파라미터도 const로 지정하여 수정이 불가능하게 만들 수 있다.

예를 들어 다음 함수는 const 파라미터를 넘겨받고 있는데, 함수 바디에서 파라미터 param을 변경하려면 컴파일 에러가 발생한다.

void vunc(const int param)
{
	//변수 param에 대한 변경은 허용되지 않음
}

const 변수나 파라미터 사용에 있어서 두 가지 특별한 케이스인 cosnt포인터와 cosnt참조에 대해 자세히 알아보자.


const 포인터

포인터 변수가 여러 단계의 포인터 연산을 통해 간접적으로 이용되면 cosnt의 적용이 까다로워진다. 예를 들어 다음 코드를 보자.

int* ip;
ip = new int[10];
ip[4] = 5;

 

위 코드에서 변수 ip에 대해 const를 적용하려 한다고 생각해보자.

변수 ip자체의 변경을 막는 것이 목적인지 아니면 ip가 가리키는 데이터의 변경을 막는 것이 목적인지 먼저 따져봐야 한다. 다시 말해, 두 번째 라인 ip = new int[10] 이 실행되지 않게 하고 싶은지 아니면 세 번째 라인 ip[4] = 5가 실행되지 않게 하고 싶은지 알아야 한다.

 

만약 후자라면, 즉 포인터가 가리키는 데이터가 변경되는 것을 막고 싶다면 cosnt키워드를 다음처럼 사용해야 한다.

cosnt int* ip;
ip = new int[10];
ip[4] = 5; // 컴파일 안됨!

 

이제 ip가 가리키는 데이터는 수정할 수 없게 된다.

 

다른 방식으로, 다음과 같이 선언해도 같은 효과를 얻을 수 있다.

int const* ip;
ip = new int[10];
ip[4] = 5; // 컴파일 안됨!

 

const 키워드를 int앞에 두든 뒤에 두든 기능적으로는 아무런 차이가 없다.

만약 가리키는 데이터가 아닌 포인터 ip 자체를 const로 하고 싶다면 다음처럼 사용한다.

int* const ip = nullptr;
ip = new int[10]; 	// 컴파일 안됨!
ip[4] = 5			// 오류! null포인터에 대한 역참조

 

이렇게 하면 ip자체에 대한 변경은 불가능하게 된다. 단, 선언과 동시에 초기화해야 컴파일된다. 초기화 값은 위 코드처럼 nullptr일 수도 있고 다음처럼 새로 할당받은 메모리가 될 수도 있다.

int* const ip = new int[10];
ip[4] = 5;

 

포인터 자체뿐만 아니라 가리키는 데이터까지 const로 지정하는 것도 가능하다. 이때는 cosnt를 다음과 같이 사용한다.

int const* const ip = nullptr;

 

다음과 같이 다른 방식으로 선언해도 같은 효과를 얻을 수 있다.

cosnt int* const ip = nullptr

 

비록 이런 문법이 다소 혼란스럽지만 원칙은 매우 간단하다. const키워드는 자신의 왼쪽에 오는 대상에 적용된다. 예를 들어 위에서 소개한 코드를 다시보자.

int const* const ip = nullptr;

 

왼쪽에서 오른쪽으로 하나씩 보면, 먼저 첫 번째 const는 int의 오른쪽에 있다. 따라서 cosnt는 int 데이터, 즉 ip가 가리키는 값에 적용된다. 따라서 ip가 가리키는 값은 변경할 수 없다. 두 번쨰 cosnt는 *의 오른쪽에 있다. 따라서 in의 포인터, 즉 변수 ip에 적용된다. 따라서 ip자체의 값, 즉 포인터의 변경이 불가능하다.

 

그런데 cosnt위치에 대한 한 가지 예외 때문에 혼란스러워진다. 첫 번째 const는 다음처럼 왼쪽이 아닌 오른쪽에 적용될 수 있다.

const int* const ip = nullptr;

 

그런데 이런 '예외적인' 상황이 오히려 더 흔하게 사용된다.

이 원칙을 다음처럼 다단계의 포인터 간접 접근에 적용될 수 있다.

const int * const * const * const ip = nullptr;

 

변수 선언이 복잡할 때 const 의 적용 방식을 이해하는 또 다른 방법은 오른쪽에서 왼쪽으로 읽어나가는 것이다. 예를 들어 int* const ip 의 경우 오른쪽에서 부터 읽으면 'ip는 const포인터(*)로 int를 가리킨다' 가 된다. 또 다른 예로 'int const* ip'를 오른쪽에서 부터 읽으면 'ip는 포인터(*)로 const int를 가리킨다' 로 해석된다.


const 참조

참조형 변수에 대한 const적용은 포인터의 경우보다 훨씬 간단하다.

 

첫 번째 이유는 참조형 자체가 원래 const속성을 가지기 때문이다. 참조형 변수는 한 번 초기화된 이후에는 가리키는 대상을 변경할 수 없다. 그래서 C++언어 차원에서 참조형 변수를 const로 제한하는 것이 허용되지 않는다.

 

두 번째 이유는 참조형 변수는 포인터처럼 여러 단계로 우회적인 데이터 접근을 할 일이 없고 보통 한 단계만으로 끝난다. 앞서 설명했듯이 참조에 대한 참조는 만들 수 없다. 다단계의 우회 접근이 발생할 경우는 참조 대상 자체가 포인터인 경우 뿐이다.

 

그래서 C++프로그래머가 'const 참조' 라고 말할 때는 다음과 같은 것을 의미한다.

int z;
const int& zRef = z;
zRef = 4;			// const값에 대한 변경 시도로 컴파일 에러 발생

 

int에 대해 const를 적용함으로써 zRef에 값이 대입되는 것을 막는다. 앞서 설명한 내용을 다시 한 번 상기하면 const int& zRef는 int const& zRef와 같다. 한가지 짚고 넘어가야 할 점은 zRef const로 선언하더라도 변수 z에는 아무런 영향이 없다는 것이다. zRef를 통해 값을 변경하는 대신 변수 z를 직접 변경하면 된다.

 

cosnt참조는 함수 파라미터에 가장 흔하게 그리고 유용하게 사용된다. 어떤 변수를 파라미터로 넘길 때 단순히 성능 최적화를 목적으로 참조형을 사용했다면 의도하지 않게 함수 안에서 변경되는 것을 막기 위해 const를 사용할 수 있다. 다음은 const참조로 파라미터를 선언하는 예다.


10.2.1.2 const 메서드

클래스 메서드를 const로 선언할 수 있다. 메서드를 const로 선언하면 그 함수 안에서는 클래스 멤버 데이터 중 mutable로 선언된 것을 제외하고는 그 값을 변경할 수 없다. 즉, const메서드는 객체에 변경을 가할 수 없다


102.1.3 constexpr 키워드

C++에는 상수 표현식이라는 개념이 있다. 배열을 선언할 때 크기 값을 지정하는 경우처럼 상수 값이 요구되는 곳에 상수 표현식을 사용한다. 다음과 같이 상수 표현이 요구되는 곳에 상수가 아닌 구문을 사용하면 C++에서 오류로 처리된다.

cons tint getArraySize() { return 32; }
int main()
{
	int myArray[getArraySize()]; // C++에서는 허용되지 않음
    return 0;
}

 

constexpr 키워드를 이용해서 문제의 getArraySize()메서드를 다음처럼 다시 정의할 수 있다. 상수 표현식은 컴파일 타임에 계산된다.

constexpr int getArraySize() { return 32; }
int main()
{
	int myArray[getArraySize()]; // 허용됨
    return 0;
}

 

심지어 constexpr을 다음처럼 상수 표현식의 구성요소로도 사용할 수 있다.

int myArray[getArraySize() + 1]; // 허용됨

 

constexpr을 이용하면 컴파일러가 최적화를 훨씬 효과적으로 할 수 있다. 단, 함수를 constexpr로 선언하려면 다음과 같이 꽤 많은 제약 사항을 지켜야 한다. 상수 표현식을 컴파일러가 컴파일 타임에 계산 완료할 수 있어야 하고, 이것은 그 표현식이 런타임에만 가능한 부가 효과를 일으켜서는 안 된다는 것을 의미한다.

  • 함수의 본체는 한 개의 리턴문으로 만들어져야 하고 goto문이나 try/catch 블록이 포함되어서는 안 되며 익셉션을 발생시켜서도 안 된다. 단, 다른 constexpr함수를 호출하는 것은 허용된다.
  • 함수의 리턴 타입은 반드시 리터럴 타입 이어야 하며 void로 선언할 수 없다.
  • constexpr으로 선언하려는 대상이 클래스의 메서드일 경우 그 메서드는 virtual 로 선언될 수 없다.
  • 모든 함수 인자는 리터럴 타입이어야 한다.
  • constexpr 함수가 호출될 수 있으려면 그 시점에 함수의 전체 구현부가 컴파일러에 의해 해석된 이후여야 한다. 즉, 일반 함수는 함수 선언부만 있는 상태에서 컴파일된 후 나중에 링크될 수 있지만 constexpr함수는 매크로처럼 호출 이전에 구현부가 컴파일러에 이미 노출되어 있어야 한다.
  • dynamic_cast는 허용되지 않는다.
  • new와 delete도 허용되지 않는다.

constexpr 생성자를 이용하면 내장 타입 외에 자체적으로 정의한 타입으로도 상수 표현 변수를 만들 수 있다.

constexpr 생성자는 다음 원칙을 따라야 한다.

  • 모든 생성자 인수는 리터럴 타입이어야 한다.
  • 생성자의 바디가 try블록에 들어가서는 안 된다.
  • 생성자 바디는 constexpr의 바디가 가지는 요구 사항을 똑같이 만족해야 한다.
  • 모든 데이터 멤버는 상수 표현식으로 초기화되어야 한다.

예를 들어 다음 Rect클래스는 위 요건에 맞추어서 생성자를 정의하고 있다. 그리고 constexprgetArea()를 정의하여 계산을 수행한다.

class Rect
{
	public:
	constexpr Rect(int width, int height)
    	:	mWidth(width), mHegiht(height) {}
        constexpr int getArea() const { return mWidth * mHeight; }
    private:
    	int mWidth, mHeight;
};

 

이 클래스를 통해 다음처럼 constexpr객체를 만들어 사용할 수 있다.

constexpr Rect r(8, 2);
int myArray[r.getArea];	//허용됨

10.2.2 static 키워드

C++에서는 static키워드를 서로 다른 여러 용도로 사용할 수 있다. 각 활용 케이스마다 별도의 키워드를 정의하지 않고 이렇게 중의적으로 정의한 것은 키워드가 늘어나는 것을 피하려는 의도도 있다.


10.2.2.1  static 데이터 멤버와 static메서드

클래스 데이터 멤버와 메서드는 static으로 선언할 수 있다. static데이터 멤버는 다른 멤버와 달리 객체별로 따로 존재하지 않는다. 대신 클래스 수준에서 모든 객체에 공통으로 속하는 단 하나의 데이터만 존재한다. 심지어 생성된 객체가 하나도 없어도 클래스만 정의하면 객체와 독립적으로 존재한다.

 

static메서드도 이와 비슷하게 객체 수준이 아니라 클래스 수준에서 존재한다. static메서드는 특정 객체에 종속되어 호출되지 않는다.


10.2.2.2 static 링킹

링킹 단계에서 static키워드가 어떻게 활용되는지 알아보기 전에 , C++에서 링킹이 어떤 개념인지 이해해야 한다.

C++ 소스 파일이 컴파일 될 때 소스별로 독립적인 오브젝트 파일이 생성되고 이 오브젝트 파일들을 하나로 연결함으로써 실행 바이너리가 만들어진다.

 

이때 함수나 변수의 이름을 기준으로 오브젝트 파일에서 일어나는 함수 호출이나 변수 참조가 서로 연결되는데,

 

같은 소스 안에서 연결 되는 경우를 내부 링킹(internal linkage)[static링킹 이라고도 한다] 이라고 하고,

 

외부 소스와 연결되는 경우를 외부 링킹(external linkage)이라 한다.

 

기본적으로 함수나 전역 변수는 외부 링킹이 적용된다. 하지만 명시적으로 static 키워드를 적용하여 강제로 내부 링킹이 적용되게 만들 수도 있다. 예를 들어 두 개의 소스파일 FirstFile.cpp와 AnotherFile.cpp가 있다고 하자, 다음은 FIrstFile.cpp다.

void f();
int main()
{
	f();
    return 0;
}

 

그런데 위 소스 파일은 함수 f()의 프로토타입은 선언하고 있지만, 그 정의부는 존재하지 않는다. 다음은 AnotherFile.cpp다.

#include <iosteam>
using namespace std;
void f();
void f()
{
	cout << "f\n";
}

 

이 파일은 함수 f()의 포로토타입과 정의부가 모두 존재한다. 두 개 이상의 소스 파일에서 같은 프로토타입의 함수를 선언하는 것은 아주 정상적인 상황이다. 사실 #include를 통해 헤더 파일을 포함하는 과정은 사용할 함수의 프로토타입을 선언하기 위한 것으로 같은 #include를 사용하는 소스 파일들은 같은 함수 프로토타입 선언을 포함되게 된다.

 

위 예제처럼 함수 프로토타입을 소스 파일별로 각각 선언할 수 있지만, 굳이 헤더 파일을 이용하는 이유는 한 곳에 함수 프로토타입을 정리해놓음으로써 유지 보수를 편리하게 하려는 목적이다.

 

위의 각 소스 파일은 아무런 에러 없이 컴파일되고, 링크도 문제없다. 왜냐하면 main()에서 호출하는 함수 f()는 외부 링크를 통해 다른 파일에서 찾아지기 때문이다.

 

그런데 AnotherFile.cpp파일 안에서 함수 f()를 static으로 선언한다고 가정해보자. static선언은 함수 프로토타입을 선언할 떄 앞쪽에 static 키워드를 붙이면 된다. 프로토타입을 static으로 선언했다면 함수 정의부에서 static 키워드를 반복해서 붙일 필요 없다. 단, 프로토타입 선언이 정의부보다 앞에 있어야 한다.

#include <iosteam>
using namespace std;
static void f();
void f()
{
	cout << "f\n";
}

 

이렇게 바꾸더라도 각 소스 파일을 컴파일하는 데는 아무런 문제가 없다. 하지만 링크 단계에서 f()가 내부 링크(static 링크) 만 허용되는 관계로 외부 파일인 FirstFile.cpp에서는 f()를 찾을 수 없게 된다. 어떤 컴파일러는 하나의 소스 파일 안에 정의부만 있고 호출되지 않는 함수가 static으로 선언되어 있을 떄 경고 메시지를 출력하기도 한다. 사용처 없이 정의부만 있다는 것은 다른 파일에서 사용됨을 암시하기 때문이다.

 

static키워드의 링킹 관련 기능을 대신하기 위해 무명 네임스페이스(anonymous namespace)기능이 도입되었다. 어떤함수를 static으로 선언하는 대신 다음처럼 이름없는 네임스페이스로 감싸면 내부 링킹이 적용된다.

#include <iosteam>
using namespace std;
namespace
 {
 	void f();
    void f()
    {
    	cout << "f\n";
    }
}

 

같은 소스 파일의 무명 네임스페이스 블록 안에 있는 코드는 서로 이용할 수 있지만, 외부에 있는 소스 파일에서는 접근할 수 없다. 이러한 동작은 static키워드의 기능과 같다.


extern키워드

static과 연관된 키워드로 extern키워드가 있다. extern키워드는 static키워드와 반대의 기능을 가진다. 즉, 명시적으로 외부 링킹할 대상을 선언한다. extern키워든느 몇몇 특정한 경우에 활용된다. 예를 들어 const나 typedef는 기본적으로 내부 링킹이 적용되나 extern키워드로 외부 링킹이 적용되게 바꿀 수 있다.

 

그런데 extern키워드는 조금 복잡하다. 어떤 심벌을 extern으로 지정하면 컴파일러는 코드를 정의가 아니라 선언으로 취급한다.

 

예를 들어 변수라면 컴파일러가 메모리를 할당하지 않는다. 메모리가 할당되게 하려면 다음 코드처럼 extern선언과 별도로 extern없이 변수 선언을 한번 더 해야한다.

extern int x;
int x = 3;

 

두 번 선언하는 대신 다음처럼 extern 선언과 동시에 값을 초기화하면 컴파일러가 정의로 취급한다.

extern int x = 3;

 

그런데 변수 정의가 있는 소스 파일 안에서는 extern선언이 특별히 필요 없다. 전역 변수는 기본적으로 외부 링킹이 적용되기 때문이다. 대신 정의된 변수를 참조하는 외부 파일에서는 extern 선언이 유용하다. 아래 소스가 FirstFile.cpp에 들어있다고 하자.

#incldue<iosteam>
using namesapce std;
extern int x;

int main()
{
	cout << x << endl;
}

 

FirstFile.cpp는 외부에 정의된 변수 x를 extern으로 선언하여 이용하고 있다. 만약 변수 x를 extern키워드 없이 선언하면 컴파일러가 변수 x의 메모리를 할당하게 되고 나중에 링킹단계에서 다른 소스  파일에 정의된 변수 x와 중복되어 에러가 발생한다. extern키워드를 이용하면 모든 소스 파일에서 전역적으로 변수에 접근할 수 있다.

 

하지만 전역 변수의 사용 자체를 권장하지 않는다. 전역 변수는 혼란스러울 뿐만 아니라 버그의 온상이다. 특히 큰 규모의 프로그램에서 문제가 두드러진다. 전역 변수는 static 클래스 데이터 멤버와 메서드로 대체하여 이용하는 것이 바람직하다.


10.2.2.3 함수 내 static 변수

static키워드의 마지막 사용 예는 함수 리턴 이후에도 계속 유지되는 static 로컬 변수다. 함수 내 static 변수는 함수 안에서만 접근 가능한 전역 변수와 같다. 가장 흔한 static변수 활용 케이스는 어떤 작업을 위한 초기화가 수행되었는지 기억해서 중복 초기화를 피해야 할 때다. 

 

다음 코드는 이러한 사용 예의 흔한 패턴이다.

void performTask()
{
	static bool initialized = false;
    if (! initialized) {
    	cout << "initializing\n";
        // 초기화 수행
        initialized = true;
    }
    // 작업 수행
}

 

하지만 static변수는 혼란스럽다. 그리고 언제나 static변수를 피할 수 있는 더 나은 코드 구조화 방법이 있다. 위와 같은 초기화 상태 기억 예에서는 클래스의 생성자가 static변수를 대신할 좋은 방법이다.

 

독립적인 sattic변수의 사용은 피하는것이 좋다.

static변수 대신 객체 안에서 상태를 기억하는 것이 좋다.

 

위 예제에서 performTask()구현 방식은 스레드 세이프 하지 않다. 이 코드는 레이스 컨디션을 일으킬 수 있다. 멀티스레드 호나경에서는 아토믹이나 스레드 동기화 메커니즘을 사용해야 한다. 


10.2.3 로컬 변수가 아닌 변수들의 초기화 순서

전역 변수와 static클래스 데이터 멤버에 관련된 이야기를 마무리하기 전에 이런 변수들의 초기화 순서를 짚고 넘어가자.

 

모든 전역 변수와 static클래스 데이터 멤버는 main()함수가 실행되기 전에 초기화가 이루어진다. 한 소스 파일 안에 선언된 변수들은 그 선언 순서대로 초기화된다. 예를 들어 다음 파일에서 멤버 Demo::x 는 변수 y보다 먼저 초기화된다.

class Demo
{
	public:
    	static int x;
};
int Demo::x = 3;
int y = 4;

 

하지만 로컬 변수와 달리 여러 소스 파일에 나뉘어 선언된 전역 변수에 대해서는 초기화 순서가 특별히 보증되지 않는다. 예를 들어 두 소스 파일에 전역 변수 x와 y가 각각 선언되어 있다면 x와 y중 어느 것이 먼저 초기화될지 알 방법이 없다. 보통은 초기화 순서가 어떻게 되든 특별히 문제가 되지 않지만. 어떤 전역변수 또는 static변수가 다른 변수에 의존할 떄는 문제가 된다.

 

객체에 대한 초기화는 그 생성자의 호출을 의미한다. 전역 객체 생성 시 그 생성자에서 다른 전역 객체에 접근할 수 있는데, 이때 객체가 이미 생성되어 있다고 가정해버릴 수 있다. 만약 두 전역 객체가 서로 다른 소스 파일에 선언되어 있다면, 한쪽이 이미 생성되었다고 가정할 수 없고 객체의 초기화 순서를 조정할 수도 없다.

 

그리고 초기화 순서는 컴파일러의 종류에 따라서, 심지어 같은 종류의 컴파일러라도 버전에 따라서 다를 수 있고, 소스파일이 추가되어도 달라질 수 있다.

 

로컬변수가 아닌 서로 다른 파일에 정의된 변수들의 초기화 순서는 정의되어 있지 않다.


10.2.4 로컬 변수가 아닌 변수들의 소멸 순서

로컬 변수가 아닌 변수들은 초기화된 순서의 반대 순서로 소멸된다.

 

여러 소스 파일에 나뉘어 선언된 전역 변수들은 초기화 순서가 정의되어 있지 않기 떄문에 소멸 순서 또한 정의되어 있지 않다.

728x90