
전방선언
다음 코드는 컴파일에러를 발생시킨다 왜일까?
int main()
{
std::cout << "The sum of 3 and 4 is: " << add(3, 4) << '\n';
return 0;
}
int add(int x, int y)
{
return x + y;
}
이유는 컴파일러가 코드를 순차적으로 컴파일하기 때문이다, 따라서 main()에서 add()를 호출할 때 add의 선언이 보다 밑에 있기 때문에 add 식별자를 찾지 못하여 컴파일 에러가 발생한다
이러한 에러를 해결하기 위해서는 2가지 방법을 사용할 수 있다
1. 함수 정의 재정렬
int add(int x, int y)
{
return x + y;
}
int main()
{
std::cout << "The sum of 3 and 4 is: " << add(3, 4) << '\n';
return 0;
}
add()를 호출하기 전에 미리 정의를 해놓는 방식으로 재정렬 하게 되면 컴파일 에러를 발생시키지 않을 수 있다 (main()에서 호출하는 add()가 무엇인지 알기 때문에)
하지만 이러한 방식은 규모가 큰 프로젝트에서는 비효율적이다 (아예 다른 파일에 있는 함수들은 재정렬이 불가능)
또한 서로를 호출하는 함수도 재정렬이 불가능하다
2. 전방선언
전방선언은 컴파일러에 미리 식별자를 알리는 방법이다
함수의 선언을 미리 작성하게 되면 컴파일러에 해당 함수 식별자를 알리게 되고 함수가 어떻게, 어디에 정의되어있는지 상관 없이 함수를 호출할 수 있다
전방선언은 다음과 같이 할 수 있다
int add(int x, int y); //전방선언, 이를 함수 프로토타입이라 한다
int main()
{
add(10, 20);
}
이때 함수의 매개변수의 이름은 함수 선언부의 일부로 간주되지 않기 때문에 따로 작성하지 않아도 된다
int add(int, int); //이렇게도 가능
하지만 이렇게 매개변수의 이름을 작성하지 않으면 해당 매개변수가 어떤 의미인지 파악하기 힘들기 때문에 매개변수 이름까지 같이 작성하는걸 권장한다
전방선언은 순서로부터 독립적인 방식으로 함수를 정의할 수 있으며 순환 종속성을 해결할 수 있다
함수를 전방선언하고 정의하지 않으면 LNK에러가 발생한다 (컴파일은 되지만 링크 단계에서 실패)
선언이란
선언은 컴파일러에게 식별자의 존재와 연관된 정보를 제공한다
int add(int x, int y); //int반환형의 int 2개를 매개변수로 가지는 함수 식별자 정보
int a; //int타입의 a라는 이름의 변수 식별자 정보
정의란
정의는 선언한 식별자를 실제로 구현하거나 인스턴스화 하는것이다
int add(int x, int y)
{
return x + y;
}
int a; //정의이자 선언
int a { 10 }; //이건 초기화임
C++에서 모든 정의는 곧 선언이다 그렇기 때문에 int a와 같은 구문은 정의이자 선언이 된다
하지만 역은 성립하지 않는다 모든 선언이 정의는 아니다, 정의가 아닌 선언을 순수 선언 (pure declaration)이라 하며 전방선언이 여기에 포함된다
컴파일러는 식별자를 만나게 되면 해당 식별자의 사용이 valid한지 체크한다 (이때 선언만 확인하고 정의는 컴파일러가 실제로 보지 않아도 된다, 그렇기 때문에 다른 파일에서 정의된 함수를 사용할 수 있는것, 물론 예외는 있다 (template))
One Definition Rule (ODR)
C++에는 ODR이라는 규칙이 존재한다
1. 같은 범위에서의 함수, 변수, type 혹은 template들은 하나의 정의만 가질 수 있다
int add(int x, int y)
{
return x + y;
}
int add(int x, int y)
{
return x + y;
}
int main()
{
int x{};
int x{ 5 };
}
//ODR 1번 규칙 위배 (중복 정의)
2. type, template, inline function/variable은 정의가 동일한 한 다른 파일에 중복된 정의를 가질 수 있다
새로운 cpp파일을 만들고 여기에 함수를 만들어보자
//Add.cpp
int add(int x, int y)
{
return x + y;
}
컴파일러는 파일들을 개별적으로 컴파일한다 따라서 컴파일러는 다른 파일의 소스코드를 알지 못하고 이전에 컴파일 된 코드의 내용도 알지 못한다
int main()
{
std::cout << "The sum of 3 and 4 is: " << add(3, 4) << '\n'; // compile error
return 0;
}
따라서 위 코드는 add식별자를 찾지 못해 컴파일 에러가 발생한다
이러한 방식은 굉장히 불편해보이지만 전부 분명한 의도가 있다
1. 소스 파일을 어떤 순서로든 컴파일이 가능
2. 특정 소스파일을 수정하면 수정한 해당 소스 파일만 따로 컴파일이 가능
3. 서로 다른 소스파일에서의 식별자 이름 충돌 가능성을 줄일 수 있다
결국 위의 컴파일 에러를 해결하기 위해서는 전방선언을 해야한다
전방선언을 하게 되면 컴파일러는 add()를 호출할 때 add.cpp의 add함수 정의와 연결한다
식별자 명명 충돌
C++에서는 모든 식별자가 모호하지 않아야 한다 (같은 범위에서 같은 식별자 이름 사용X)
이렇게 되면 컴파일러나 링커는 어떤 식별자를 사용 해야할지 구분을 할 수 없기 때문에 에러가 발생한다
(명명 충돌)
//Add.cpp
int add(int x, int y)
{
return x + y;
}
//main.cpp
int add(int x, int y)
{
return x + y;
}
int main()
{
add(10, 20);
}
해당 코드는 독립적으로 컴파일 되어 컴파일 에러는 발생하지 않지만 add라는 함수 이름 충돌이 있기 때문에 링크 에러가 발생한다
(add() 호출 시 어떤 add()를 호출할지 모호하기 때문)
프로젝트의 사이즈가 점점 커질수록 이러한 명명 충돌은 더 자주 발생할 가능성이 높아진다, C++에는 이러한 명명 충돌을 방지하기 위한 방법이 존재한다
(local variable 이름 충돌 방지를 위한 { }와 같은 방법들)
namespace
namespace는 namespace안에 선언된 식별자가 다른 범위에서 선언된 식별자와 별개로 간주되는 소스코드 영역이다 (동일한 식별자명을 가져도 충돌이 발생하지 않는다)
하지만 같은 namespace 안에서는 모든 식별자는 고유해야 한다
함수에서는 { }를 이용하여 local variable의 식별자를 고유하게 사용할 수 있다
int Foo()
{
int a;
{
int a;
}
}
namespace 내부에는 선언과 정의만 가능하다 (조건문, 반복문과 같은 실행 가능한 문장 (Executable Statement)들은 namespace에서 사용이 불가능하지만 namespace안의 함수를 정의 하는 부분에서는 사용이 가능하다)
namespace Math
{
const double PI = 3.14159; // 선언 및 정의
double square(double x)
{
return x * x; // 실행 가능한 문장
}
}
namespace에는 이름을 붙힐 수 있다
namespace 이름
{
}
namespace Math
{
double PI = 3.141592;
}
특정 namespace 내부의 식별자들을 사용하는 방법은 여러가지가 있다
Math::PI; //명시적으로 namespace:: 범위를 지정하여 식별자 접근
여기서 ::란 범위 지정 연산자이다
또 다른 방법은 using을 이용하는 방법이다
using namepsace std;
PI;
이렇게 하면 명시적으로 namespace::를 할 필요가 없다, 하지만 이는 지양한다 왜냐하면 명명 충돌이 발생할 수 있기 때문이다
using namespace std;
int cout()
{
return 5;
}
int main()
{
cout << "Hello, world!";
return 0;
}
위 코드는 컴파일 에러가 발생한다, Global namespace에 선언된 cout()을 호출하는지 std::cout을 호출하는지 링커는 알 수 없기 때문이다
따라서 namespace::로 내부 식별자에 접근하는게 namespace의 존재 의의에 더 적합하다
Global namespace
만약 함수나 변수를 전역으로 선언하게 되면 이 식별자들은 암묵적으로 Global namespace의 일부로 간주된다 따라서 같은 namespace로 구분하기 때문에 명명충돌이 발생하게 된다
(위에서 add() 에러가 발생한 이유)
그렇기 때문에 위의 namespace 규칙도 그대로 적용된다
void foo();
int x;
int y { 5 };
x = 5; //Global namespace에서 executable statement를 사용할 수 없다
std namespace
C++이 처음 설계 되었을 때 std namespace의 식별자들을 std::없이 사용이 가능했었지만 점점 규모가 커지며 명명 충돌 가능성이 높아져 std namespace를 사용하게 되었다 (standard) (cout, cin 등 다양한 함수를 가진 라이브러리이다)
전처리
프로젝트를 컴파일 하기 전에 각 소스코드 파일들은 전처리 단계를 거치게 된다, 이때 전처리기는 소스코드 파일의 텍스트에 다양한 조작을 한다
이때 실질적으로 원본 소스 코드 파일을 수정하는 방식은 아니고 일시적으로 메모리나 임시파일을 사용하여 변경한다
전처리기는 주석을 제거하고 각 코드파일이 endline(줄바꿈)으로 끝나게 한다, 그리고 아주 중요한 #include 지시문을 처리한다
이렇게 전처리기가 코드 파일 처리를 끝낸 결과를 Translation Unit이라 하고 이는 컴파일러에 의해 컴파일 된다 (이 결과에는 전처리기 지시문이 포함되지 않고 전처리기 지시문 출력만 컴파일러로 전달된다)
이러한 Translation Unit은 단일 cpp와 그 cpp와 연결된 모든 .h로 구성된다 (다른 .h를 포함할 수 있기 떄문에 연결된 모든 .h파일임)
전처리, 컴파일 ,링크 전체 과정을 translation이라 한다
#include
전처리기는 소스코드 파일을 위에서 아래로 진행하며 전처리기 지시문 (#으로 시작하고 줄바꿈으로 끝나는 지시문)을 찾는다
이러한 전처리 지시문은 전처리기에 위에서 정리한 특정 텍스트 조작을 지시한다
(전처리 지시문은 C++구문을 이해하지 못한다, 전처리 지시문은 자체 구문을 가지고 있음)
대표적인 전처리문 #include < iostream > 으로 정리해보자
#include <iostream>
int main()
{
std::cout << "Hello World" << std::endl;
}
전처리기는 #include < iostream >을 iostream이라는 파일의 내용으로 바꾼다, 그렇기 때문에 iostream의 식별자들을 전부 사용할 수 있는것이다
macro
#define으로 매크로를 만들어 사용이 가능하다, 매크로란 입력값을 대체 출력값으로 변환되는 방식을 정의한 것이다
매크로에는 ;을 사용하지 않고 일반 식별자와 같은 명명 규칙을 사용한다, 보통 대문자로 많이 사용한다
#define NAME "Kelvin"
Kelvin이라는 입력값이 NAME이라는 대체 출력 값으로 변환된 것이다
std::cout << NAME < std::Endl; //KELVIN
이러한 매크로를 Object-like macro라고 한다
Object-like macro는 C Style의 상수 표현법이다, 이는 C++에서 더는 필요하지 않은 방식이고 이미 거의 Legacy 코드에만 존재한다 (사용하지 않는게 좋다)
Conditional Compile (조건부 컴파일)
조건부 컴파일 지시문을 사용하여 특정 조건에서만 컴파일, 컴파일 하지 않을 수 있다
대표적으로 #ifdef, #ifndef, #endif가 있다
#define TESTDEF
int main()
{
#ifdef TESTDEF
std::cout << "TESTDEF is def!" << std::endl;
#endif
#ifdef TESTDEFFFFF
std::cout << "TESTDEFFFFF is def!" << std::endl;
#endif
}
#ifdef는 옆에 작성한 식별자가 #define을 통해 이미 정의되었는지 체크하고 정의 되었다면 아래를 컴파일한다, 이때 이를 닫아주는 역할이 #endif다 (ifndef는 반대임, 정의되지 않으면 컴파일)
따라서 위의 코드는 TESTDEF is def! 가 출력되고 밑에는 컴파일 되지 않는다
이는 조금 더 C++스타일로 작성이 가능하다
#if defined(TESTDEF)
#endif
원하는 부분을 컴파일에서 그냥 제외시키고 싶다면 #if 0을 사용한다, #if 1로 변경하면 다시 컴파일에 포함시킨다
int main()
{
std::cout << "TESTDEF is def!" << std::endl;
#if 0
std::cout << "TESTDEFFFFF is def!" << std::endl; //컴파일 되지 않음
#endif
}
위에서 정리한 매크로 대체값은 전처리 지시문에서는 값을 대체하지 않는다
#define FOO 9
#ifdef FOO //전처리 지시문이기 때문에 9로 대체되지 않는다
std::cout << FOO << '\n'; //전처리 지시문이 아닌 코드이기 때문에 9로 대체된다
#endif
추가로 #define의 디테일을 정리해보자
void foo()
{
#define NAME "Kelvin"
}
int main()
{
std::cout << NAME << std::endl;
}
위 코드는 에러를 발생시키지 않는다, 분명 보면 NAME이 foo() 내부에 선언되어 유효범위가 { }일 것 같아 main()에는 사용하지 못할것 같지만 전처리기는 C++구문을 이해하지 않기 때문에 local로 선언된 것이 아니여서 사용이 가능하다
마지막으로 전처리 지시문은 해당 지시문이 작성된 파일 내부에서만 유효하다, 따라서 #include되지 않았다면 다른 파일에서는 영향을 줄 수 없다
//Add.cpp
#include <iostream>
void doSomething()
{
#ifdef PRINT
std::cout << "Printing!\n";
#endif
#ifndef PRINT
std::cout << "Not printing!\n";
#endif
}
//main.cpp
void doSomething();
#define PRINT
int main()
{
doSomething();
}
PRINT가 main에서 define되었지만 main.cpp에서만 유효하기 때문에 Add.cpp의 코드에서는 전혀 영향을 받지 않는다 따라서 Not Printing이 출력된다