상위 문서: C++
1. 개요
2. 헤더
include
C++은 하나의 프로그램을 다수의 소스 코드가 모여 이루는 구조다. 원래 C++은 C언어의 구조를 그대로 가져왔기에 코드 구조도 비슷하다. 반드시 정해진 건 아니지만, 보통 소스 코드를 선언(Declaration)과 구현(Implementation) 두가지로 나누어 작성하곤 한다. 선언부에는 프로그램에서 사용할 객체의 식별자 선언만 넣고, 구현부에는 객체의 실제 정의를 넣는 방식이다. 아직도 많은 소스 코드는 이 규격을 따른다. 요약하자면 먼저 필요한 코드만 최소한으로만 작성하고 나서, 다른 파일에 실제 구현을 작성하는 규칙이다 [1].
2.1. 선언부
[외부 헤더 가져오기]
객체 선언;
여기서 가져온다는(Import) 행위는 헤더 파일을 삽입(
include
)함으로써 이루어진다. 삽입은 목표한 헤더 파일의 내용을 그대로 내 헤더로 복사하는 것이다. 때문에 헤더 파일과 헤더를 삽입하는 파일은 같은 파일로 합쳐지는 모양새가 된다.함수의 경우 구현 내용을 적을 필요는 없고 이름과 매개변수를 작성하면 된다. 매개변수 이름의 경우
C++17
부터는 선언부에도 꼭 기입할 필요는 없다. 하지만 보통
IDE에서 주석 표시를 해줄 때는 선언부 기준으로 보여주므로 변수 이름을 적는 게 좋다. 클래스의 경우 private
, public
, protected
접근 권한에 따라서 내부에서 사용할 멤버의 원형을 선언할 수 있다. 헤더에 선언만 존재해도 다른 코드에서 사용하는 데에는 문제가 없다. 이때 C++의 이름공간을 활용하면 헤더의 삽입으로 생기는 식별자의 중복 문제를 해소할 수 있다.주의할 점은 헤더에서
using namespace
를 사용했다가는, 그 헤더를 사용하는 모든 파일에도 같은 이름공간이 적용되어 예기치 않은 동작이 발생할 수 있으므로 주의해야 한다. 헤더 파일에서는 이름공간을 생략하지 않는 것이 좋다.확장자의 경우
*.h
혹은 *.hpp
가 일반적이다.2.2. 구현부
[원본 헤더 가져오기]
[외부 헤더 가져오기]
객체 구현;
include
한 다음에 헤더에서 선언한 객체의 내용을 실제로 구현한다.확장자의 경우 비주얼 스튜디오에서는 C언어 및 C++ 소스코드에
*.cpp
확장자를 기본으로, G++에서는 *.cc
확장자를 기본으로 사용한다. 애초에 확장자는 프로그래머가 구별하기 쉽게 하기 위함이지 딱히 표준은 없다.2.3. 프로그램 예제
2.3.1. 헤더 파일
- <Document.hpp>
#!syntax cpp #include <cstdint> #include <string> #include <string_view> namespace namuwiki { class Document { private: static constinit std::int64_t documentIndex = 0; public: constexpr Document(std::string_view contents) : myText(contents) // C++20이상에서는 std::string{ std::string_view }로 바로 문자열 생성이 가능함 , myIndex(++documentIndex) {} virtual constexpr ~Document() noexcept = default; virtual std::string_view GetAuthorName() const = 0; virtual void Print() const; [[nodiscard]] constexpr std::int64_t GetIndex() const noexcept; protected: std::string myText; std::int64_t myIndex; }; } namespace namuwiki::guideline { inline namespace version_r000 { class Guideline : public namuwiki::Document { public: using Super = namuwiki::Document; constexpr Guideline() : Super("문서 생성") {} virtual constexpr std::string_view GetAuthorName() const override { return "관리자"; } }; } // 인라인 이름공간 version_r001은 version_r000를 덮어 씌움 inline namespace version_r001 { class Guideline : public namuwiki::Document { public: using Super = namuwiki::Document; constexpr Guideline() : Super("편집 지침 작성") {} virtual std::string_view GetAuthorName() const override; }; } } void PrintContents(const namuwiki::Document& document); // 상수 표현식인 멤버 함수는 인라이닝해도 문제 없음 constexpr std::int64_t namuwiki::Document::GetIndex() const noexcept { return myIndex; }
2.3.2. 구현 소스 파일
- <Document.cpp>
#!syntax cpp #include "Document.hpp" #include <print> // <string>와 <string_view>는 MyPrinter.hpp로부터 삽입 되어있음 void PrintContents(const namuwiki::Document& document) { document.Print(); } std::string_view namuwiki::guideline::version_r001::Guideline::GetAuthorName() const { return "관리자"; } using namespace namuwiki; void Document::Print() const { std::println("Contents: {}", myText); }
2.3.3. 실행부
- <Main.cpp>
#!syntax cpp #include "Document.hpp" int main() { namuwiki::Document hangeul_doc{ "나무위키" }; namuwiki::Document english_doc{ "NamuWiki" }; hangeul_printer.Print(); PrintContents(english_printer); }
include
하고 main
함수를 작성한다. 가령 클래스를 사용하려면 클래스가 선언된 헤더를 include
해주면 된다. 참고로
IDE를 쓰고 있다면 문제는 없지만 별도의 컴파일러를 쓴다면 구현 파일 또한 컴파일러에게 잘 전달해 주어야 링크 오류가 발생하지 않는다.2.4. 문제점
- <C++ 예제 보기>
#!syntax cpp #include <string> class MyPrinter { public: void Print() const; void Print(const std::string& prefix) const; private: std::string myText; }; // 오류! 중복 정의된 함수 void Print(const MyPrinter& printer) { printer.Print(); // 식별자 찾지 못함 } // 오류! 중복 정의된 함수 (오버로딩 때문이 아님!!!!) void Print(const MyPrinter& printer, const std::string& prefix) { printer.Print(prefix); // 식별자 찾지 못함 }
MyPrinter
클래스의 Print
메서드를 참조하는 Print
함수를 구현하는 예제다. 구현하는 방법에는 두가지 방법이 있다. 다른 소스 파일에 구현하거나, 같은 헤더 파일에 구현하는 방법이 있다. 다른 소스 파일로 분리하면 아무 문제가 없다. 그런데 파일 접근 권한이 없어서 파일 생성을 못하고 수정 뿐이 못한다면? 아니면 소스를 외부에 배포하고 싶은데 간편한 사용을 위해 같은 헤더 파일에 구현하려고 한다고 해보자. 헌데 이렇게 하면 필시 링크 오류가 발생한다.더욱이 가관인 건 오류가 두 곳에서 발생한다는 점이다. 예시를 한번 보자. 첫번째는
Print
함수가 중복 정의되었다는 오류다. 심지어 이 오류는 헤더 파일을 하나의 소스 파일에서만 써도 발생한다. 정확히 말하자면 헤더의 삽입은 파일을 병합하는 것이므로 같은 스코프에 정의된 객체는 그 내용이 인라인이면 식별자의 중복 문제가 발생한다. 두번째는 MyPrinter::Print
식별자가 정의되지 않았다는 오류다. 이 오류는 식별자의 선언과 구현이 엇갈려서 생기는 문제다. Print
는 오버로딩까지 된 함수라서 분명히 보이지 않는 extern "C++"
도 붙어있을 것이다. 그러나 여전히 링크 오류가 발생한다. 어째서일까?2.4.1. C언어에서의 해결 방법
- <C++ 예제 보기>
#!syntax cpp #ifndef MYCLASS_H #define MYCLASS_H class MyPrinter { public: void Print() const { ...; } private: std::string myText; }; // 아직도 오류 발생: 중복 정의된 함수 /*static */ void Print(const MyPrinter& printer) { printer.Print(); } #endif
#ifdef
, #endif
를 조합하는 방법이 이용되어 왔다. 클래스는 문제가 없지만, 그러나 여전히 함수는 문제가 된다. 전처리기 키워드를 쓰더라도, 함수는 여전히 참조하는 클래스의 메서드, 다른 함수들의 구현부를 필요로 한다.그간 C언어에서 이를 해결하는 방법은
static
지시자를 써서 변수와 함수를 즉시 정의하는 방법, extern
지시자를 써서 선언과 정의를 분리해서 구현하는 방법이 있었다. 상기한 헤더와 소스 파일의 분할 방법론은 여기서 나온 것이다.C++에서는 헤더 삽입에 대해 다른 규칙이 도입되고 이름공간의 사용 등으로 일부분 해결되었다. 그러나 C++에서 식별자 중복 때문에 발생하는 문제가 전부 해결된 것은 아니다. 자세한 내용은 후술.
3. 모듈
모듈 (Module)C++20
완고할 것 같았던 C++에도 변화의 바람이 불었으니, C++20에서 헤더를 대체할 수 있는 모듈(Module)이라는 새로운 소스 파일 규격을 도입했다. 모듈은 보다 유연하고 안전한 코드를 작성할 수 있는 소스 파일 규격이다. 모듈은 모듈의 식별자를 써서 가져올 수 있고, 또는 기존의 헤더를 가져오는 식으로 사용할 수도 있다. 표준 라이브러리의 경우
import std;
혹은 호환성을 위해 import std.compat;
으로 가져올 수 있다. 혹은 기존의 헤더 이름을 그대로 써서 import <header>;
와 같이 가져올 수도 있다.종래의 헤더 삽입(Include)은 사실 소스 코드를 가져온다(Import)는 개념을 포함하긴 하는데 여러가지 부작용이 있었다. 대상 헤더의 원본을 그대로 다른 헤더로 복사하는 것은 간편하지만 이후 프로그래밍의 발전사와는 동떨어진 개념이 되어버렸다. 과한 식별자 추가 및 의존성 문제, 보안 문제, 그리고 문자열로 관리되는 특성 상 유연한 의존성 수정이 어려웠다.
개발자는 필요한 객체만 모듈 밖으로 내보낼 수 있다. 헤더처럼 모든 객체를 복사 붙여넣기하는 게 아니다. 개발자가 선택적으로 인터페이스를 만들 수 있다는 장점이 있다. 모듈은 분할,
private
조각 등 다양한 규격이 지원된다. 그리고 종래의 헤더 구조와 같이 별도의 구현부를 만들 수도 있다. 그리고 각각의 모듈 규격은 모두 별도의 파일로 분리할 수 있다.이전의 헤더를 사용하는 방식은 여전히 남아있으나, C언어의 방식 및 연결고리를 하나씩 대체해가고 있으며, 헤더도 과거의 유산이 될 예정이다. 그러나 C++20의 모듈은 현재 MSVC에서나 완전히 지원하고 나머지는 불완전하기 때문에 Windows 플랫폼에서만 온전히 사용할 수 있는 상태다. 이는 시간이 해답으로 보여진다.
3.1. 선언부
3.1.1. 모듈 선언
export module Module-Identifier; |
소스 파일의 최상위에
export module Module-Identifier;
문을 작성하면 그 파일은 모듈 파일이 된다.3.1.2. 의존성 가져오기
import Ex-Module-Identifier; export import Ex-Module-Identifier;
|
3.1.3. 객체 내보내기
export Identifier; |
3.1.4. 전방위 선언
module ; |
3.1.5. 비공개
module : private ; |
3.2. 하위 모듈
export module Module-Identifier:SubModule-Identifier; |
3.2.1. 하위 의존성 가져오기
import :SubModule-Identifier; export import :SubModule-Identifier;
|
3.3. 구현부
module Module-Identifier; |
3.4. 프로그램 예제
3.4.1. 모듈 파일
- <Socket.ixx>
#!syntax cpp
3.4.2. 하위 모듈 파일
- <SocketProtocol.ixx>
#!syntax cpp
- <SocketResult.ixx>
#!syntax cpp
3.4.3. 외부 모듈 파일
- <InternetProtocol.ixx>
#!syntax cpp
3.4.4. 구현 소스 파일
- <Socket.cpp>
#!syntax cpp
3.4.5. 실행부
- <Main.cpp>
#!syntax cpp
4. 번역 단계
번역 단계4.1. 번역 단위
번역 단위 (Translation Unit)4.1.1. 전처리기 토큰
전처리기 (Preprocessor)4.1.2. 객체
객체 (Object)자료형, 열거형, 구조체와 클래스, 결합체 (Union), 변수, 함수
4.1.3. 코드 범위
코드 범위 (Scope)다른 프로그래밍 언어가 대개 그렇듯이 C++에도 스코프, 내지는 코드 범위의 개념이 있다. 그 동안 프로그래밍에서 스코프라는 것이 정확히 뭔지 모르고 사용했을 것이다. C++의 스코프는 어떤 객체 안에서 나타나는 하위 번역 단위 중 하나로서 식별자와 스택이 공유되는 문맥(Context)이며 혹은 외부에서 불러온 프로그램일 수도 있다. 스코프 안에 정의한 객체들은 스코프 내부 혹은 스코프를 참조하는 다른 스코프에서 사용할 수 있다.
스코프의 종류에는 함수, 클래스, C언어부터 내려온 헤더 그리고 모듈C++20 이 존재한다. 함수의 매개변수와 지역 변수는 함수 외부에서 참조할 수 없다. 클래스의 비정적 멤버 변수는 클래스의 인스턴스를 생성하기 전까지는 참조할 수 없다. 또한 클래스는 접근 권한을 지정해서 참조할 수 있는 멤버를 한정지을 수 있다. 헤더와 모듈은 직접 가져오기 전까지는 내부에 작성된 객체를 사용할 수 없다.
5. 언어 연결성
언어 연결성 (Language Linkage)5.1. 외부 연결
외부 연결 (External Linkage)외부 연결은 객체가 코드 범위 외부에서 보이는 상태임을 뜻한다. 외부 연결인 객체는 심지어 현재 코드 범위 뿐만 아니라 다른 곳에도 존재할 수 있다. 그래서 외부 연결이 지정된 객체는 구현이 없이 선언만 있어도 된다. 예를 들어 함수의 선언과 구현을 분리하는 것, 클래스 멤버 함수의 선언과 구현을 분리하는 것, 그리고 헤더 및 모듈C++20 를 가져오는 행위가 외부 연결을 사용하는 것이다. 중요한 점은 헤더의 삽입 그 자체가 외부 연결이라는 점이다. 헤더의 삽입은 헤더 파일을 복사 붙여넣기하는 것과 똑같은 동작이다. 링커에선 이것을 외부 연결로 해석한다. 그래서 소스 파일에서 헤더를 삽입하고 헤더에서 선언한 함수를 구현하는 기작이 가능한 것이다 [2].
C++에선 보통 자동으로 외부 연결이 적용된다. C++의 모든 객체에는 보이지 않는
extern "C++"
이 붙어있다. 이는
C++에서는 우리가
C언어에서 그랬던 것 처럼 전처리기 키워드, 매크로, static
, extern
지시자 따위에 얽매이지 않고 자연스럽게 코드를 구현할 수 있다는 것이다. 사용자가 일부러 extern "C"
혹은 모듈에서 export
와 extern "C++"
를 조합하는 경우가 아니면 내/외부 연결이란 그렇게 중요한 의제는 아니다.
C언어 때부터 내/외부 연결의 문제는 사실상 프로그래밍 외적인 문제였으므로 이를 개발자에게 보이지 않게 치운 거라고 볼 수 있다 [3].
C언어와의 호환성은 자동으로 붙는 extern "C++"
로 대신한다. 사실상
C언어에서 작성했던 모든 객체와 헤더는 외부 연결이라고 할 수 있다.한편 코드 범위 외부로 노출된다는 건 외부 의존성이 생길 수 있고 다른 문맥에서 사용할 수 있음을 의미한다. 더 나아가 다른 문맥에서 접근할 수 있다는 말은 다른 언어나 프로그램에서도 사용할 수 있다는 뜻이며 즉 인터페이스를 말한다. 이 기능으로 매우 강력한 응용이 가능하다. 대표적인 예시로 Apple macOS의
DyLib
이나
Microsoft Windows의 DLL
이 있다. 외부 라이브러리를 쓰는 소스를 보면 외부 연결로 지정된 클래스와 함수는 있으나 구현부는 없는데, 함수의 구현부를 운영체제의 도움을 받아 외부에서 가져와서 사용한다.사용자가 명시적으로 외부 연결을 표현할 때는 이름공간 사용,
extern
연결성 지시자 사용 또는, 모듈에서 export
C++20 를 하면 된다. 외부 인터페이스를 만들려면 운영체제와 컴파일러마다 다른 명세가 지원되므로 별도로 알아보도록 하자.5.2. 내부 연결
내부 연결 (Internal Linkage)내부 연결은 해당 객체를 현재 코드 범위 안에서만 사용할 수 있음을 의미한다. 다시 말하면 현재 번역 단위(객체 또는 헤더 및 헤더를 삽입하는 소스 파일들) 안에서는 유일한 존재로 특정되는, 고유한 이름을 갖고 있다는 뜻이다. 예를 들어 C++에서 내부 연결이 자동으로 적용되는 경우는 다음이 있다. 삽입하지 않은 헤더의 객체는 사용할 수 없다. 함수의 매개변수와 함수 안에서 선언한 지역 변수는 함수 밖에서 사용하지 못한다. 소스 파일에만 선언한 객체는 헤더에서 사용할 수 없다. 또한 클래스의 비정적 멤버도 일종의 내부 연결로 취급되어서, 클래스의 중복 정의는 일어나도 클래스 메서드의 중복 정의는 일어나지 않는다.
내부 연결 객체들이 유일한 존재임을 보장하는 건 공짜가 아니다. 변수의 경우 값이 대입된 경우와 그렇지 않은 경우가 혼재되어서는 안된다. 함수는
inline
이 강제된다. 그리고 매개변수 자료형과 noexcept
C++17 등 서명이 같아야 비로소 같은 함수임을 보장받을 수 있다. 클래스/결합체(Union)의 경우 부모 클래스, 멤버, 특성(Attribute), 메모리 정렬이 같아야 한다. 열거형(Enumeration)의 경우 부모 자료형, 멤버 열거자, 특성, 메모리 정렬이 같아야 한다.또한 유일한 존재라는 말은 해당 객체의 구현이 다른 어디에도 없다는 것인데, 곧 객체의 구현을 반드시 선언과 같이 해줘야 한다. 변수는 값을 할당해주거나 기본값으로 초기화가 가능해야 하고, 함수는 구현을 즉시 해줘야 하며, 클래스와 열거형 역시 전방위 선언만 있으면 안되고 구현 몸체(Class Body)가 있어야 한다.
- <C++ 예제 보기>
#!syntax cpp namespace { class MyPrinter { public: // 경고: 내부 연결이지만 구현부가 없음 void Print() const; private: std::string myText; }; void Print(const MyPrinter& printer) { // 정의가 없어도 사용하는 데 문제없음 printer.Print(); } } namespace { int aaaaa; int bbbbb = 0; } int aaaaa; // 오류! 'aaaaa'가 중복해서 선언되었습니다.
static
연결성 지시자 또는 이름없는 이름공간(Unnamed Namespace)C++17 을 사용할 수 있다. static
은 변수와 함수에, 이름없는 이름공간은 C++의 모든 객체에 적용할 수 있다. 익명 이름공간의 멤버는 모두 내부 연결로 처리된다. 즉 익명 이름공간 안의 변수는 유일한 존재가 되며 구현부가 없는 함수도 멀쩡하게 사용할 수 있다. 이러면 함수 또는 함수에서 참조하는 객체가 유일한 식별자로 정해지고 중복 정의 문제가 해결된다.5.3. 모듈 연결
모듈 연결 (Module Linkage)C++20
모듈을 링크하려면
export
한 객체에는 외부 연결이, export
하지 않은 객체는 아예 없는 존재로 취급되던가 혹은 내부 연결이 적용되어야 하는데... 이러면 문제가 발생한다. 이유는 바로 내부 연결의 제약 때문이다.객체를 선택적으로 내보낸다는 것은 클래스의 특징을 따라 은닉성을 파일 범위로 확장한 거라고 볼 수 있다. 그러나 클래스의
protected
, private
과는 달리 내부 연결은 외부에서는 사용할 수도 없는 데다가 객체(함수, 클래스)의 정의가 선언과 동반되어야 하는 등 까다로운 규칙이 있다. 내부 연결의 특징 때문에 모듈 외부는 물론이고, 분할 모듈 및 private
구현부에서조차 사용하지 못할 것이다. export
하지 않은 클래스나 함수라도 구현을 따로 만들 수는 있어야 하지 않겠는가? 산하의 모듈 파일들을 같은 번역 단위로 치고 컴파일 해주려는데, 기껏 만든 함수나 클래스를 못 쓰면 안되므로 새로운 연결 상태가 도입된 것이다. 곧 모듈 연결은 내부 연결과 외부 연결의 중간 상태라고 말할 수 있다. 대상은 모듈에서 내부 연결이 아니고, export
하지 않은 객체에 적용된다.5.4. 내외부 연결의 혼용
지금까지 3가지 연결성을 모두 설명했는데 그러면 이 시점에서 의문점이 들 수 있다. 가져오지 않은 헤더/모듈이 내부 연결이면 헤더를 삽입하면 외부 연결이 되는 것인가? 클래스의 비정적 멤버는 내부 연결인데 클래스의 멤버를 어떻게 사용할 수 있는 걸까? 지금 모듈에서 내보내지 않은 클래스[export안한]가 있는데 이 클래스의 인스턴스를 외부 연결인[export된] 함수로 생성해서 내보내면 어떻게 될까?모두 합리적인 의문이며 대답은 다음과 같다: 헤더와 모듈의 내부 연결이라는 것은 헤더와 모듈이 작성된 파일, 그리고 헤더와 모듈을 가져온 파일을 한데 이르는 스코프를 말한다. 즉 헤더와 모듈이 어떤 연결성을 가진다는 건 실제로 헤더와 모듈이 사용되는 문맥에 따라 달라지는 것으로서 사용하지 않으면 아직 내외부 연결성을 따지기는 이른 것이다. 다만 헤더와 모듈에서 정의한 객체들이 다른 소스 파일에서의 관점에서 보면 내부 연결이라는 점은 맞다. 클래스의 비정적 멤버는 인스턴스의 연결성을 따라간다. 클래스의 인스턴스를 헤더와 모듈 외부에서 만들거나 사용할 수 있으면 외부 연결이 된다. 반면 오직 클래스가 정의된 헤더와 모듈, 혹은 헤더와 모듈의 구현부에만 있는 클래스면 내부 연결이 된다. 클래스의 정적 멤버는 외부 연결로 취급된다.
protected
는 부분적으로 외부 연결인걸로 취급한다. 생뚱맞은 규칙 같지만 이 규칙이 없으면 클래스의 정적 멤버를 아예 사용할 수 없을 것이다.즉 내외부 연결은 유일하게 결정되지 않으며 어떤 순서로 어떻게 중첩되었는지, 그리고 문맥에 따라서도 달라진다. 하지만 사용자가 내외부 연결을 명시했다면 그게 우선적으로 적용된다. 다음 예제를 보자.
- <C++ 예제 보기>
#!syntax cpp // 이름없는 이름공간 안에 있으면 무조건 내부 연결이 된다. // 설령 extern 이라도. namespace { // 내부 연결인 이름공간 namespace InternalLinkage { // 내부 연결인 클래스 class MyTablet { public: static std::string hwVender; static std::string hwIdentifier; }; class MyPhone : public ::Device { public: static inline constinit std::string hwVender = "Google"; static inline constinit std::string hwIdentifier = "Pixel 5"; protected: std::string callNumber; std::size_t callCounts; std::string IMEI; }; } // 내부 연결인 클래스 class MyDesktop : public Device { public: static std::string hwVender; static std::string hwIdentifier; }; // extern이지만 내부 연결 함수 extern std::string_view GetMyDeviceID(std::string_view simple_name) noexcept; } std::string InternalLinkage::MyTablet::hwIdentifier = "IPad 6th Gen"; std::string MyDesktop::hwVender = "TG삼보"; std::string MyDesktop::hwVender = "TG-DT281-GA51B-011";
5.4.1. 국소적 번역 단위 개체
국소적 번역 단위 개체 (Translation-unit-local Entities)내부 연결인 객체가 어떤 외부 연결인 다른 객체에 의해 강제로 외부로 노출된 상태를 말한다.
6. 이름 검색
이름 검색 (Name Lookup)- <C++ 예제 보기>
#!syntax cpp #include <iostream> #include <complex> int main() { std::complex left{ 30, 5 }; std::complex right{ 60, 2 }; // (1-1) // `*` 연산자는 `std` 이름공간안에 있는데 실행할 수 있다 // result_1: std::complex<int> == std::complex{ 1700, 360 } auto result_1 = left * right; // (1-2) // std::cout은 이름공간 `std` 안의 클래스 `std::iostream<char, std::char_traits<char>>`의 extern 인스턴스다 // std::endl은 이름공간 `std` 안의 endl(std::ostream&) 함수다 // << 연산자는 이름공간 `std` 안에 있다 // std::operator<<(std::ostream&, const std::complex&)을 실행한다 std::cout << "result_1 = " << result_1 << std::endl; // (2) // `std::complex`에 대한 `cos`의 오버로딩은 C언어의 `cos`와 다르게 `std` 이름공간 안에 있는데 실행할 수 있다 // result_2: std::complex<int> == std::complex{ 11, 73 } auto result_2 = cos(left); // (2-2) // << 연산자는 이름공간 `std` 안에 있다 // std::operator<<(std::ostream&, const int&)을 실행한다 std::cout << "imaginary number of result_2 = " << result_2.img() << std::endl; }
std
안에 있다. 그러나 using namespace std;
지시자 없이도 무사히 연산자 수행이 가능하다. using std::operator~;
를 쓸 필요도 없다. 대체 C++ 컴파일러는 무슨 조화를 부린 것일까?이때 컴파일러가 내세우는 규칙은 바로 이름 검색 (Name Lookup)이다. 사실 이 규칙은 모든 객체에 적용된다. 연산자에만 특별 규칙을 둔 게 아니라 공평하게 적용되는 규칙이다. 어떤 식별자가 사용되면 컴파일러는 가능한 후보를 어디에 있든지 모두 찾아낸다. 사용자는 식별자를 적었을 뿐이지만 컴파일러는 식별자가 유일하지 않을 가능성, 클래스, 함수, 변수, 이름공간 넷중에 헷갈릴 가능성,
const
여부나 값 범주 문제 등등등 고려해야 할 점이 많다. 다시 말해서 모든 표현식에서 사용 가능한 클래스. 함수, 변수, 이름공간의 후보를 결정하는 규칙이 바로 이름 검색이다. 이 규칙은
C언어와
C++의 경계를 나누는 핵심 요소라고 말할 수 있다. 덕분에 함수, 함수의 매개 변수, 클래스(구조체)의 이름을 서로 구분할 수 있다. 그렇지만 여전히 전역 변수와 클래스는 인라이닝을 할 수 없다. 템플릿으로만 간접적으로 중복된 이름을 만들 수 있다.6.1. 인자 의존성 검색
- <C++ 예제 보기>
#!syntax cpp namespace NamuWiki { struct Vector2 { // Vector2는 float 필드 두개 뿐이므로 복사와 이동이 가능하다 // 이항 양의 부호 연산자 (덧셈 연산자) Vector2 operator+(Vector2& rhs) noexcept { Vector2 result = *this; // *복사 result.x += rhs.x; result.y += rhs.y; return result; // *RVO } // 이항 음의 부호 연산자 (뺄셈 연산자) // 매개변수가 꼭 const lvalue일 필요는 없으나 성능 때문에 하는 게 좋다 // 이때 `operator+`는 구조체 Vector2 내부가 아니라 이름공간 NamuWiki에 정의된다 friend Vector2 operator-(const Vector2& lhs, const Vector2& rhs) noexcept { // C++의 결집 구조체 초기화 (Aggregate initialization) return Vector2{ lhs.x - rhs.x, lhs.y - rhs.y }; } // 곱셈 연산자 // 매개변수가 꼭 const lvalue일 필요는 없으나 성능 때문에 하는 게 좋다 // 이때 `operator+`는 구조체 Vector2 내부에 정의된다 // inline이 아니라서 정의를 늦게 해줘도 된다 Vector2 operator*(const Vector2& lhs, const Vector2& rhs) noexcept; // 곱셈 연산자 // 매개변수가 꼭 const lvalue일 필요는 없으나 성능 때문에 하는 게 좋다 // 이때 `operator/`는 구조체 Vector2 내부에 정의된다 // 그러나 inline이기 때문에 반드시 클래스 정의와 함께 같은 헤더 어딘가에 정의해줘야 한다 inline Vector2 operator/(const Vector2& rhs) noexcept; // 단항 양의 부호 연산자 // 이때 `operator+`는 구조체 Vector2 내부가 아니라 이름공간 NamuWiki에 정의된다 static friend Vector2& operator+(Vector2& vector) noexcept { vector.x = std::abs(vector.x); vector.y = std::abs(vector.y); return vector; } // 단항 음의 부호 연산자 // 단항 음의 부호 연산자는 friend가 아니면 static일 수 없다. // 이때 `operator-`는 구조체 Vector2 내부에 정의된다 Vector2& operator-() noexcept { x = -std::abs(x); y = -std::abs(y); return *this; } // 단항 비트 반전 연산자 // 단항 비트 반전 연산자는 friend가 아니면 static일 수 없다 // 이때 `operator~`는 구조체 Vector2 내부가 아니라 이름공간 NamuWiki에 정의된다 static friend Vector2& operator~(Vector2& vector) noexcept { vector.x = std::abs(vector.x); vector.y = std::abs(vector.y); return vector; } float x, y; }; Vector2 Vector2::operator/(const Vector2& rhs) noexcept { // C의 지정 초기화 (Designated initialization) return Vector2 { .x = this->x / rhs.x, .y = this->y / rhs.y }; } Vector2 Add(const Vector2& lhs, const Vector2& rhs) noexcept { return lhs.operator+(rhs); } Vector2 Subtract(const Vector2& lhs, const Vector2& rhs) noexcept { return lhs.operator-(rhs); } struct Vector3 : public Vector2 { float z; } Vector3 Add(const Vector3& lhs, const Vector2& rhs) noexcept { return Vector3{ lhs.x + rhs.x, lhs.y + rhs.y, lhs.z }; } } NamuWiki::Vector2 NamuWiki::operator*(const NamuWiki::Vector2& lhs, NamuWiki::const Vector2& rhs) noexcept { return Vector2{ lhs.x * rhs.x, lhs.y * rhs.y }; } int main() { NamuWiki::Vector2 vec1{ 50, 100 }; NamuWiki::Vector2 vec2{ 400, 70 }; NamuWiki::Vector3 vec3{ 200, 910, 30 }; // (1) // NamuWiki::Vector2 내부의 (this const Vector2&, const Vector2&)를 이항 +연산자 호출 vec1 += vec2; // (2) // result_2 == NamuWiki::Vector2{ 540, 170 } // NamuWiki 내부의 (const Vector2&, const Vector2&)를 받는 이항 +연산자 호출 auto result_2 = vec1 + vec2; // 3) // result_3 == NamuWiki::Vector2{ 50, 100 } // NamuWiki 내부의 (const Vector2&, const Vector2&)를 받는 이항 -연산자 호출 auto result_3 = vec1 -= vec2; // (4) // result_4 == NamuWiki::Vector2{ -400, -70 } // NamuWiki::Vector2 내부의 단항 -연산자 호출 auto result_4 = vec2.operator-(); // (5) // result_5 == NamuWiki::Vector2{ -2000, -7000 } // NamuWiki 내부의 (const Vector2&, const Vector2&)를 받는 이항 *연산자 호출 auto result_5 = NamuWiki::operator*(vec1, vec2); // (6) // result_6 == NamuWiki::Vector2{ -350, -30 } // NamuWiki 내부의 (const Vector2&, const Vector2&)를 받는 함수 Add 호출 auto result_6 = Add(vec1, vec2); // (7) // result_7 == NamuWiki::Vector3{ 250, 1010, 30 } // NamuWiki 내부의 (const Vector3&, const Vector2&)를 받는 함수 Add 호출 auto result_7 = Add(vec1, vec3); }
만약 이름 검색이 함수의 이름을 찾는데 이용되면 인자 의존성 검색(Argument Dependency Lookup)이라고 칭한다. 의존성 검색이라는 것은 컴파일러가 실행할 수 있는 함수의 후보를 찾아내는 걸 의미한다. 후보에는 함수가 아닌 객체의 선언(함수와 이름이 같은 클래스나 변수도 마찬가지), 다른 클래스의 멤버를 올리진 않는다. C++에서 이 규칙 덕분에 함수와 연산자의 오버로딩을 할 수 있고, 클래스, 이름공간, 함수와 변수의 이름이 전부 같아도 구별할 수 있다. 가령 생성자만 해도 클래스와 이름이 같은데 멀쩡하게 존재할 수 있지 않은가?
이는 분명히 장점이 많으나 문제도 많다. 함수는 인자 추론, 의존성 검색을 통해 의도하지 않은 함수를 실행할 수 있다. 이렇게 찾아낸 함수 후보가 원하는 게 아닐 가능성이 있다는 것이다. 이것 때문에 표준 라이브러리 변경사항에 Qualifying Namespace std, Proof ADL 같은 게 가득해진 원인이 됐다. 특히 표준 라이브러리의 범용 알고리즘과 숫자 연산 함수들이 이런 문제가 매우 컸다. 그리고 여러 라이브러리에서 같은 자료형에 대해 같은 이름의 함수를 작성했다면 오동작을 일으킬 가능성이 크다. 다음 단락에서 말하겠지만 무려 세가지 치명적인 문제를 발생시킨다. 간단히 소개하자면 모호한 함수 후보 문제, 잘못된 함수 후보 문제, 이름공간 무시가 일어난다.
6.1.1. 모호한 함수 후보 문제 & 이름공간 무시
- <C++ 예제 보기>
#!syntax cpp namespace NamuWiki { long long Calculator(long long a); long long Add(int lhs, int rhs); } long long Calculator(long long a); long long Add(int a, int b); int main() { // (1) auto result_1 = Calculator(254906214713); // (2) auto result_2 = NamuWiki::Calculator(254906214713); // (3) // 전역 범위의 ::Add 호출 auto result_3 = Add(1000, 6000); // (4) // 오류! 모호한 함수 후보 using namespace NamuWiki; auto result_5 = Add(1000, 6000); }
6.1.2. 잘못된 함수 후보 문제
- <C++ 예제 보기>
#!syntax cpp int main() { }
그러면 함수 사용하는 부분에 모조리 이름공간을 붙이면 해결이 될까? 안타깝게도 그렇지 않다.
사용자의 함수 오버로딩과 오버라이딩 무시가 발생한다.
6.1.3. 해결 방법
- <C++ 예제 보기>
#!syntax cpp namespace NamuWiki { long long Add(int lhs, int rhs); class Calculator { public: long long operator()(long long value) const noexcept; }; Calculator Calculator{}; } long long Calculator(long long a); int Add(int a, int b); int main() { // (1) auto result_1 = Calculator(254906214713); // (2) // NamuWiki::Calculator의 인스턴스 Calculator에서 () 연산자 호출 auto result_2 = NamuWiki::Calculator(254906214713); // (3) // 전역 범위의 ::Add 호출 auto result_3 = Add(1000, 6000); // (4) // 오류! 클래스 Calculator는 () 연산자로 실행할 수 없습니다 // C++23 부터는 static operator()가 가능하다 using namespace NamuWiki; auto result_4 = NamuWiki::Calculator(254906214713); // (5) // 오류! 모호한 함수 후보 using namespace NamuWiki; auto result_5 = Add(1000, 6000); }
함자 또는 함수 객체는 C++ 특유의
()
연산자의 오버로딩을 통해 함수처럼 실행할 수 있는 클래스다. 함자 클래스의 인스턴스를 만들고, 함수와 똑같이 인스턴스의 식별자에 ()
와 인자를 전달하면 ()
연산자가 수행된다.- <C++ 예제 보기>
#!syntax cpp class Functor { public: int operator()() const noexcept { return 500; } double operator()(double add) const noexcept { return myDouble + add; } double myDouble; }; class Adder { public: int operator()(const int& lhs, const int& rhs) const noexcept { return multiplier * (lhs + rhs); } float operator()(const float& lhs, const float& rhs) const noexcept { return static_cast<float>(multiplier) * (lhs + rhs); } int multiplier = 1; }; int main() { // (1) test1.myDouble == 0 Functor test1; // test1_result: int == 500 auto test1_result = test1(); // (2) test2.myDouble == 200.0 // ()로 객체 생성 Functor test2( 200.0 ); // test2_result: double == 1200.0 auto test2_result = test2(1000); // (3) test3.multiplier == 1 Adder test3{}; // test3_result: int == 17000 auto test3_result = test3(8000, 9000); // (4) test4.multiplier == 7 Adder test4{ 7 }; // test4_result: float == 4200.0f auto test4_result = test3(300, 300.0f); }
()
연산자는 인스턴스에 직접 접근하기 전까지는 의도하지 않은 ADL에서 안전하다. 클래스의 비정적 메서드는 내부 연결의 특징이 있어 클래스 안의 함수 정의가 외부에 보이지 않는다.C++20에서는 알고리즘 함수의 문제를 해결하기 위해 모듈이 추가되고, 이름공간
std::ranges
까지 추가하고, 함자에다가 concept
[6]를 아득바득 발라서 해결을 봤다. 거기다가 이 함자들은 니블로이드(Niebloid)라는 특별한 존재다. 니블로이드는 어떤 이름공간 안에 있고, 인스턴스를 생성하려면 특별하고 숨겨진 방법 뿐이 없고, 복사, 이동, 대입이 불가능하고, 상속도 불가능하다. 자명한 클래스의 완전한 대척점의 특징을 가지고 있다.7. 값 범주
값 범주 (Value Category)C++은 자료형부터 함수까지 모든 부분이 값의 전달로 이루어졌다고 말할 수 있다. 여기서 값이란 것은 메모리 상의 어떤수치를 나타내는 요소로써, 식별자(identifier), 예약어(Keyword), 문장부호(Punctuation), 특성(Attribute), 구문(Statement), 템플릿(Template)을 제외한 모든 것이다. 일반적으로 생각되는 변수, 상수로 구별하는 것을 넘어 어디서 어떻게 생겨나고, 어디서 사라지는지, 어떻게 정의되는지, 어떻게 변화되는지, 등 프로그램에서의 메모리 흐름을 완벽히 추종하는 항목이다.
C++ 언어 요소 중에서도 매우 심화되는 내용으로써 필수적으로 알아야하는 내용은 아니다. 다만 일반화 프로그래밍에서 &, const&, &&의 구분, 리터럴 표현, 임시 객체를 이용한 최적화가 필요한 경우 공부가 필요하다.
7.1. lvalue
Left Referenced Value- 이름이 있는 값.
-
const
가 아니라면 수정할 수 있는 값. -
생성자나 함수에 이름을 전달하면 한정자 없는 경우,
&
,const&
순으로 오버로딩을 우선시함 -
임시 객체의 함수에서 반환된 경우
&&
로 취급 -
이름이 있는 객체의 함수에서 반환된 경우
&
또는const&
로 취급
- 문자열 리터럴
- 변수와 상수
- 람다 표현식이 아닌 함수의 식별자
- 참조형을 반환하는 함수 구문
-
(
const
)&
배열의 인덱스 참조 구문 -
static_cast<T&>
,static_cast<const T&>
와 같이 좌측값 참조 형변환 구문
이름(식별자)이 있고 이에 따라 주소도 갖고 있는 값이다.
7.2. prvalue
Pure Right Value
* 이름이 없고 메모리 상에도 존재하지 않는 값
* & 연산자로 주소를 얻을 수 없는 값
* 즉시 사용하지 않으면 사라지는 임시 값 [7]
* 다른 이름이 있는 변수에 할당하기 전까지는 수정할 수 없는 값
* 선언만 된 미완성 클래스이면 안됨
* 추상 클래스면 안됨
* 부모 포인터로 자식 포인터를 받을 수 없음
* 참조형 또는 포인터가 아니면
*
* 함수의 인자로 전달하면
* 함수의 인자로 전달하면
* 이름이 없고 메모리 상에도 존재하지 않는 값
* & 연산자로 주소를 얻을 수 없는 값
* 즉시 사용하지 않으면 사라지는 임시 값 [7]
* 다른 이름이 있는 변수에 할당하기 전까지는 수정할 수 없는 값
* 선언만 된 미완성 클래스이면 안됨
* 추상 클래스면 안됨
* 부모 포인터로 자식 포인터를 받을 수 없음
* 참조형 또는 포인터가 아니면
const volatile
이 될 수 없음 [8]*
&
일 수 없음* 함수의 인자로 전달하면
const&
보다 &&
오버로딩을 우선시함* 함수의 인자로 전달하면
&&
로 취급
- 문자열을 제외한 모든 리터럴
-
람다 표현식,
requires
제약조건식 - 반환형이 좌측 참조형이 아닌 함수 구문
-
this
- 식별자, 자료형, 열거형, 컨셉트의 이름;
-
(long)value
,static_cast<T>
와 같이 참조가 아닌 형변환 구문
숫자, 문자 등 일반적으로 사용되는 리터럴을 의미한다. 그리고 함수안에서 만든 구조체나 클래스의 인스턴스를 반환하는 것도
prvalue
다. 가령 함수 안에서 선언한 객체를 반환하는 것도 원래대로라면 참조 대상 소실이 일어나야한다. 그러나 사실은 함수에서 반환하고 난 직후에는 prvalue
로 취급하여서, 그 함수의 결과를 변수에 받았을 때 비로소 온전한 객체가 된다.7.3. xvalue
eXpired Value
* 이름이 없어 메모리 상에만 존재하는 값
* & 연산자로 주소를 얻을 수 없는 값
* 현재 문맥이 끝나면 사라지는 임시 값 [9]
*
* 선언만 된 미완성 클래스일 수 있음
* 추상 클래스일 수 있음
* 부모 포인터로 자식 포인터를 받을 수 있음
* 함수의 인자로 전달하면
* 함수의 인자로 전달하면 클래스는
* 이름이 없어 메모리 상에만 존재하는 값
* & 연산자로 주소를 얻을 수 없는 값
* 현재 문맥이 끝나면 사라지는 임시 값 [9]
*
const
가 아니라면 수정할 수 있는 값* 선언만 된 미완성 클래스일 수 있음
* 추상 클래스일 수 있음
* 부모 포인터로 자식 포인터를 받을 수 있음
* 함수의 인자로 전달하면
&
보다 &&
오버로딩을 우선시함.* 함수의 인자로 전달하면 클래스는
&&
로 취급, 클래스가 아니면 const&
로 취급
- 클래스 객체의 멤버 참조 구문
-
class.member
-
class[index]
- 임시 객체의 멤버 참조 구문
-
int result = A().Get();
-
&&
배열의 인덱스 참조 구문 -
std::move
,static_cast<T&&>
와 같은 우측값 형변환 구문 -
문맥을 종료할 수 있는 구문:
return
,co_return
,throw
[10]
마찬가지로 사용하지 않으면 사라지는 값은 맞으나,
prvalue
처럼 식별자를 갖기 전까지는 코드 상에서만 보이는 값이 아닐 수도 있다. 기존에 이름이 있었던 값이나 포인터의 형변환이 포함된다. 가상 클래스나 상속된 클래스 끼리의 형변환도 xvalue
로 취급한다.7.4. glvalue (lvalue & xvalue)
Generalized Left Value7.5. rvalue (prvalue & xvalue)
Right Value
[1]
만약 선언과 구현이 같은 파일에 정의되어 있다면 그걸 인라인(Inline)이다, 인라이닝 되어있다 등으로 칭한다
[2]
주의할 점은 헤더의 경우 선언만 넣지 않으면 중복된 객체 구현으로 인한 링크 오류가 발생할 가능성이 크다. 이는 함수에 흔히 발생하는 문제다
[3]
함수는 아직 중복 정의의 문제가 있다
[export안한]
[export된]
[6]
템플릿 제약조건, 추후 설명
[7]
이 구문이 쓰이지 않으면 최적화될때 사라진다
[8]
함수의 반환형이
const int
라면 const
가 항상 무시되고 int
인 것과 같다
[9]
이 구문이 쓰이지 않으면 최적화될때 사라진다
[10]
Return Value Optimization을 위한 조건이다