반응형

내가 inline키워드를 명시적으로 쓰는 타입의 프로그래머인데, 링커에러가 뜬다? 그러면 이 글을 보시길 바란다.

 

3D자체엔진을 제작하려고 3D리소스 래핑을 하는 도중에 inline에 대하여 엄청난 오용을 하고 있었다는 사실을 깨달았다.

// Material.h
// FBX를 로드할 때 가져오는 정보. 
class MaterialResource : public IGraphicsResource
{
	using MaterialMapArray = std::array<std::shared_ptr<Texture>, static_cast<UINT>(eMaterialMapType::SIZE)>;
public:
	explicit MaterialResource(std::wstring_view _name);
	virtual ~MaterialResource();
public:
	inline void SetMaterialMap(eMaterialMapType _mapType, std::shared_ptr<Texture> _pTexture);
	inline std::shared_ptr<Texture> GetMaterialMap(eMaterialMapType _mapType);
private:
	MaterialMapArray mMaterialMaps;
};

// Material.cpp
MaterialResource::MaterialResource(std::wstring_view _name)
	: IGraphicsResource(_name)
{
}
MaterialResource::~MaterialResource()
{
}
inline void MaterialResource::SetMaterialMap(eMaterialMapType _mapType, std::shared_ptr<Texture> _pTexture)
{
	mMaterialMaps[static_cast<UINT>(_mapType)] = _pTexture;
}
inline std::shared_ptr<Texture> MaterialResource::GetMaterialMap(eMaterialMapType _mapType)
{
	return mMaterialMaps[static_cast<UINT>(_mapType)];
}

이런 클래스가 있다.

이 클래스는 일단 SetMaterialMap() 함수를 사용하면 에러를 이렇게 낸다.

개같은...링커에러

그렇다. 그 악명높은 링커에러가 뜬다.

근데 정의와 구현부도 제대로 되어있고, 헤더가 꼬인거도 아니고....당연하게도 main문은 있다.

애초에 SetMaterialMap() 함수를 사용한 코드에서 나는 에러다. 해당 코드를 지우면 제대로 빌드가 된다.

 

뭐가 문제임? 하는 사람도 있을거고,

왜 이따구로 짬? 하는 사람도 있을거다...

일단 확실한건, 대부분은 "뭐가 문제임?" 혹은 "inline이 뭐임?" 하는 반응이 대부분일 것이다.

내가 아는 inline의 역할은 컴파일 단계에서 함수 호출을 없애고, 호출 위치에 함수의 코드를 그대로 붙여넣어 함수 호출비용을 줄이는 것으로 알고있었다.

물론 이것도 맞다. 하지만 보조적으로 한 가지 역할이 더 있다.

그건 바로 여러 파일에서 #include한 함수가 중복 정의되어도 괜찮다고 링커에게 알려주는 역할이라는 것이다......

약간 처음 들으면 애매할 수 있는데, 이해하고 보면 당연한 말이다.

이해를 돕기 위해 gpt쌤의 말을 가져오면,

더보기

inline을 쓰면 헤더 파일에서 구현해야 하는 이유는 컴파일러와 링커의 작동 방식 때문입니다. 이를 차근차근 설명드릴게요.


1. 컴파일러는 각 번역 단위에서 따로 작동

  • C++에서 코드를 컴파일할 때 **각 소스 파일(.cpp)**은 독립적인 "번역 단위"로 처리됩니다.
  • inline 함수는 컴파일러에게 "이 함수는 어디에서든 중복 정의될 수 있지만, 중복되어도 괜찮다"는 뜻을 전달합니다.

중요: inline 함수는 중복 정의를 허용하지만, 컴파일러는 이를 보장하기 위해 번역 단위마다 해당 함수의 정의를 알고 있어야 합니다.


2. 헤더 파일은 모든 번역 단위에 포함됨

  • 헤더 파일에 inline 함수를 정의하면 이를 포함하는 모든 번역 단위에서 동일한 함수 정의가 생깁니다.
  • 이 정의가 동일하다면, 링커는 이를 하나로 합쳐 문제가 없게 처리합니다.

예제:

cpp
코드 복사
// header.h inline int Add(int a, int b) { return a + b; }
  • 이 코드가 포함된 각 .cpp 파일마다 Add 함수의 정의가 복사됩니다.
  • 링커는 "이건 모두 동일한 함수다!"라고 인식하고 하나로 합칩니다.

3. CPP 파일에 정의하면 발생하는 문제

  • inline 함수는 헤더 파일에 정의되지 않으면 다른 번역 단위에서 해당 정의를 볼 수 없습니다.
  • 링커는 다른 번역 단위에서 함수의 정의를 찾을 수 없고, "이 기호를 찾을 수 없다"는 에러를 발생시킵니다.

문제 상황:

cpp
코드 복사
// header.h inline int Add(int a, int b); // 선언만 있고 구현 없음 // source.cpp #include "header.h" int Add(int a, int b) { // 구현이 cpp 파일에만 존재 return a + b; }
  • 링커는 Add 함수가 헤더에 정의되어 있어야 한다고 가정하지만, 실제 정의는 CPP에 있으므로 충돌합니다.

4. inline의 의미와 동작

  • inline의 의미: 함수가 호출될 때 **코드 삽입(inline expansion)**으로 대체될 수 있다.
  • 컴파일러가 함수의 코드를 호출자 위치에 직접 삽입하려면, 함수의 **정의(body)**를 컴파일 시점에 알아야 합니다.
  • 따라서 inline 함수는 반드시 모든 번역 단위에서 동일한 정의를 볼 수 있도록 헤더 파일에 구현해야 합니다.

5. 중복 정의를 피하기 위한 설계

  • 일반적인 함수는 여러 번 정의되면 링커가 에러를 발생시킵니다(One Definition Rule 위반).
  • 그러나 inline 함수는 특별히 "같은 정의라면 여러 번 있어도 괜찮다"는 규칙이 있습니다.
  • 이 규칙을 제대로 활용하려면, 모든 번역 단위에서 동일한 정의가 필요하므로 헤더 파일에 구현해야 합니다.

요약: 헤더에 구현해야 하는 이유

  1. 모든 번역 단위에서 동일한 함수 정의를 보장하기 위해.
  2. 함수 호출 위치에 직접 코드 삽입하려면, 컴파일러가 함수의 정의를 알고 있어야 하기 때문에.
  3. 중복 정의로 인한 링킹 에러를 방지하기 위해.

따라서, inline 함수는 헤더 파일에 정의를 작성하고, CPP 파일에서는 구현을 배제해야 안정적으로 동작합니다! 😊

따라서, 쉽게 말하면 템플릿이랑 컴파일과정이 비슷하다고 볼 수 있습니다.

템플릿도 헤더에 구현을 하지 않으면 에러가 뜨므로.....

 

그래서 이렇게 바꾸니까 되었습니다.

class Texture;

// FBX를 로드할 때 가져오는 정보. 
class MaterialResource : public IGraphicsResource
{
	using MaterialMapArray = std::array<std::shared_ptr<Texture>, static_cast<UINT>(eMaterialMapType::SIZE)>;
public:
	explicit MaterialResource(std::wstring_view _name);
	virtual ~MaterialResource();
public:
	inline void SetMaterialMap(eMaterialMapType _mapType, std::shared_ptr<Texture> _pTexture) {
		mMaterialMaps[static_cast<UINT>(_mapType)] = _pTexture; 
	}
	inline std::shared_ptr<Texture> GetMaterialMap(eMaterialMapType _mapType) { 
		return mMaterialMaps[static_cast<UINT>(_mapType)];
	}
private:
	MaterialMapArray mMaterialMaps;
};
MaterialResource::MaterialResource(std::wstring_view _name)
	: IGraphicsResource(_name)
{
}
MaterialResource::~MaterialResource()
{
}
MaterialState::MaterialState(MaterialResource* _pMaterialResource)
	: mMaterialResource(_pMaterialResource)
{
	for (int mapType = 0; mapType < static_cast<UINT>(eMaterialMapType::SIZE); ++mapType)
	{
		mCBuffer.UseMap[mapType] = TRUE;
	}
}

cpp에 구현을 없애고 h에 구현을 했습니다.

 

근데 걍 느낀게....

처음에 inlline 굳이 쓴건, 어차피 컴파일러가 자동으로 inline화를 판단하지만, inline키워드를 씀으로써 const키워드처럼 필요없지만 (물론 const는 항상 필요없는건 아니다) 명시적으로 넣어주는 습관을 가지려고 쓴건데... 이렇게 복잡한 키워드인지 몰랐다.

하...걍 inline써도 컴파일러가 inline화 무시할 때도 있고, inline안써도 컴파일러가 알아서 inline화 시킬 때도 있는데,
걍 쓰지 맙시다.....

 

반응형
반응형

ㅎㅇ 헬로 반갑습니다?

다음 포스팅 주제로 비트연산에 대해 적으려 했는데, 자료조사좀 하던 도중에 재밌는 거를 발견해서 비트연산 포스팅 이전에 미리 글하나를 써보려고 합니당


 2진 리터럴(Binary literals)이 뭔가요? 이 10덕아

넵, 2진 리터럴은 말그대로 2진 리터럴입니다(?) (퍽퍽

그전에 리터럴에 대해 초간단으로 말씀드리면, 그냥 우변에 올 수 있는 데이터? 같은 느낌입니다 ㅇㅅㅇ. 나중에 이것도 글로 써야겠다.

그런 데이터, 즉 리터럴들을 컴퓨터가 구분하기 위해 저희는 리터럴 표기법이라는 것을 써주는데요? 사실 당연하게 써왔던 것들입니다. 무서워하지 마시죠 크크 (퍽퍽

int num1 = 0x12; // 16진수를 나타내기 위해 0x를 붙임
float num2 = 1.0f; // float를 나타내기 위해 f를 붙임
const char* str = "Hello World"; // 문자열을 나타내기 위해 ""을 붙임

근데? C++11까지는 2진수에 대한 리터럴표기법이 없었습니다. (OTL)

C++14에 2진수에 대한 리터럴 표기법이 나왔다고 합니다. 그게 바로 2진 리터럴인데요 ㅇㅅㅇ.

표기도 간단합니다. 바로 보시져 ㄱ_ㄱ

// 2진 리터럴 표기
int bit = 0b1010; // 2진수를 나타내기 위해 0b를 붙임

마우스 올리면 숫자도 바로 알려줌 ㅋㅋ


이게 끝입니다.... 

사용자 정의 리터럴을 만들 수 있다는 흥미로운 사실을 발견했는데 그건 다음에 시간이 되면 포스팅해보겠습니다?...

그럼 20000

반응형
반응형

범용성을 위해 Color클래스를 만들어봤다.

class ColorF
{
private:
	float rgba[4];
public:
	ColorF(float r = 0.0f, float g = 0.0f, float b = 0.0f, float a = 1.0f)
		: r(rgba[0]), g(rgba[1]), b(rgba[2]), a(rgba[3]) // 참조 초기화
	{
		rgba[0] = r; rgba[1] = g; rgba[2] = b; rgba[3] = a;
	}
	// 아니 참조도 메모리 차지하는거 처음암 ㅁㅊ
	const float& r;
	const float& g;
	const float& b;
	const float& a;

	UINT32 GetRGB()
	{
		UINT8 r = static_cast<UINT8>(rgba[0] * 255.0f);
		UINT8 g = static_cast<UINT8>(rgba[1] * 255.0f);
		UINT8 b = static_cast<UINT8>(rgba[2] * 255.0f);
		return (r << 16) | (g << 8) | (b);
	}

	operator const float* () const { return rgba; }
	operator const Vector4() const { return Vector4(r, g, b, a); }
	ColorF& operator=(const ColorF& _val)
	{
		rgba[0] = _val.rgba[0];
		rgba[1] = _val.rgba[1];
		rgba[2] = _val.rgba[2];
		rgba[3] = _val.rgba[3];
		return *this;
	}
#ifdef _D2D1_H_
	operator D3DCOLORVALUE()
	{
		return D3DCOLORVALUE(r, g, b, a);
	}
#endif
};

 

처음 만들때 정말 바보같은 생각을 함. 답답해도 들어보삼

처음만들때 한생각 : float4개를 배열로 만든다음(배열은 연속적인 메모리니까?) r,g,b,a가 각각 배열을 가리키게 하면 메모리도 4X4 16바이트 쓸 수 있겠지? (참조는 메모리 안먹는줄 암)

 

근데 다 만들고 보니까 구조체가 48바이트를 잡아먹는거임. 뭐지? 하고 보니까 참조도 메모리를 차지함. 당연한건데 왜 그 생각을 못했지? (심지어 참조는 주소를 가지고있어야 해서 8바이트임ㅋㅋ)

 

그리고 어차피 float 배열로 안해도 구조체는 연속적인 메모리로 정리되기 때문에 그냥 r, g, b, a 각각 float로 만들면 됨.... 이러면 더 명확하고 간단한데.... 자꾸 생각에 잡아먹혀서 간단하게 못짠듯

 

다시 짜본 Color클래스

#pragma once

class ColorF
{
public:
	ColorF(float _r = 0.0f, float _g = 0.0f, float _b = 0.0f, float _a = 1.0f)
		: r(_r), g(_g), b(_b), a(_a) {}
public:
	float r, g, b, a;

	UINT32 GetRGB()
	{
		UINT8 r_ = static_cast<UINT8>(r * 255.0f);
		UINT8 g_ = static_cast<UINT8>(g * 255.0f);
		UINT8 b_ = static_cast<UINT8>(b * 255.0f);
		return (r_ << 16) | (g_ << 8) | (b_);
	}

	operator const float* () const { return &r; }
	operator const Vector4() const { return Vector4(r, g, b, a); }
#ifdef _D2D1_H_
	operator D3DCOLORVALUE()
	{
		return D3DCOLORVALUE(r, g, b, a);
	}
#endif
};

초 - 간 - 단

r주소 반환해주면 구조체는 연속적인 메모리로 정리되어있기때문에 rgba에 접근할 수 있다.

의도대로 16바이트로 나옴

굿ㅋㅋ

 

생각에 잡아먹히지 말자...ㅇㅅㅇ

반응형
반응형

C++11 에서의 타입추론은 컴파일 시간에 변수의 자료형을 자동으로 추론해준다. 사실 코딩 표준에선 auto보다 실제 자료형을 명시하는것을 선호하지만, 잘만 쓰면 매우 유용한 기능인건 틀림이 없다 ㅇㅅㅇ

프로그래머라면 응당 어떻게 동작하는지 알아야 적재적소에 잘 써먹을  수 있다는 사실..... 타입추론에 관해서 주의할 점도 많으니까 오늘 알아봅시다 ㅋㅅㅋ


타입 추론이 무엇인가?

타입 추론은 컴파일러가 코드 내에서 변수나 표현식의 타입을 "추론"해서 직접 할당해주는 것을 의미하는데요?

이 덕분에 개발자가 직접 타입을 명시하지 않아도 되고, 코드가 간결해질 수 있읍니다.

그렇다면? 타입추론을 자기가 직접 할리는 없고, 해주는 친구들이 있지 않겠습니까?

그 친구들이 바로 auto키워드와 template문법입니다. 오늘은 그중에서 auto에 대하여 알아봅시다 ㅇㅅㅇ


auto가 뭔데 씹덕아

auto는 변수의 타입 (int, char 등)을 지정해줄때의 자료형 대신 auto를 붙여주면 컴파일러가 컴파일시간에 알아서 타입을 추론해주는 친구인데요?

어떤식으로 사용하는지 한번 볼까용

// 사용 예시 ()
double	x1 = 5.0;  // double 자료형
float	x2 = 5.0;  // float 자료형
auto	x3 = 5.0;  // auto의 추론 값 : double
auto	x4 = 5.f;  // auto의 추론 값 : float

이런식으로 사용할 수 있는 친구입니다 ㅇㅅㅇ 쉽지 아니한가

근데 여기서 촉이 좋으신 분들은 이미 느꼈을텐데요?

5.0을 float에 넣을 수는 있지만 auto에 5.0을 넣으면 double이 추론됩니다. 물론 float를 사용할 때는 숫자 뒤에 "f"를 명시적으로 붙여야 되는건 맞습니다.. 그만큼 auto를 쓸 때는 사용자가 컴파일러에게 타입 추론에 대한 단서를 명확히 줘야 된다는 점입니다. 아래에서 자세히 다뤄볼게여 ㅇㅅㅇ


본격적으로 auto에 대하여 알아보자

여러 상황에 대하여 다뤄볼건데여 통수맞는 상황이 생각보다 많아서 글이 길어질 것 같습니다. ㅠ

같이 추론 값을 알아볼까요?


1. 타입이 다른 연산에 대하여

다음 타입의 추론 값을 예상해보시죠 ㅇㅅㅇ

auto	y1 = 1 + 1.0f;	  // ??
auto    y2 = 1.0 + 1.0f;  // ??

 

정답 및 설명

더보기

y1 = float

y2 = double

 

컴파일러가 타입을 추론할 때 축소 변환을 하지 않는 것을 원칙으로 연산하기 때문인데요?

축소 변환이 무엇인지 대강 말씀드리자면,

(int)1 + (float)1.5f 를 더할 때 컴파일러는 자료형을 암시적으로 통일해서 연산합니다, float를 int로 바꾼다면, 1.5는 1이 되어버립니다. 이 과정에서 원래 수인 1.5를 잃어버리게 되어서 결과적으로 1 + 1 = 2가 되어버리는데요, 반대로 int를 float로 바꾸면 1.0 + 1.5를 연산하여 2.5를 정상적으로 출력할 수 있습니다. float가 int로 변환하면서 값을 잃어버릴 수도 있을 때, 이를 축소 변환한다고 말합니다. 동일하게 float + double 연산도 double이 float에 비해 더 많은 소수점을 나타낼 수 있기 때문에 double로 캐스팅이 됩니다.

똑똑한 컴파일러는 축소 변환으로 인한 연산 미스를 미연에 방지하기 위해 확대 변환을 하여 연산을 합니다. 그래서 auto로 타입 추론시 저런 결과가 나오는 겁니다. ㅇㅅㅇ


2. 상수(const), 참조(&) 추론에 대하여

그럼 이건 어떨까요?

const int z1 = 1;	// const int 자료형을 선언
auto	  z2 = z1;	// 그럼 여기서 auto는 const int가 맞겠지?
z2 += 1;			// const값을 변경하는데 컴파일 에러가 안난다??
const int z3 = 1;	// const int 자료형을 선언
auto&	  z4 = z3;	// 무슨 자료형일까?
z4 += 1;			// 이 상황은 에러가 난다

이미 주석으로 결과는 말해드렸는데요?

z2와 z4가 주석의 의도와 다르게 타입 추론이 되었기 때문입니다.

z2와 z4의 타입은 어떻게 추론되었을까요?

 

정답 및 설명

더보기

z2 = int

z4 = const int&

 

z2는 const int, 즉 상수 값 1을 대입받아 z2 = 1과 같은 대입 연산이 이루어 졌습니다. 그 결과 int로 추론이 되었습니다. 때문에 z2 += 1 연산이 컴파일 에러없이 잘 동작합니다.

z4는 auto&, 즉 z3의 원본에 대한 값을 참조하므로 타입을 그대로 받아올 수 있었습니다. 그 결과 const int를 참조하기 때문에 const int&로 추론이 되었습니다. 때문에 z4 += 1 연산은 상수연산으로 인식되어 컴파일 에러가 납니다.


3. 포인터(*) 추론에 대하여

C++인데 포인터가 빠지면 섭섭하죠잉? 해당 코드의 추론값도 예상해 보시져 ㅇㅅㅇ

int w1 = 1;
auto w2  = &w1;  // w2의 자료형은?
auto* w3 = &w1;  // w3의 자료형은?

 

정답 및 설명

더보기

w2 = int*

w3 = int*

 

주소를 받았으니 당연히 int*인건 맞습니다. 하지만 w2, w3는 각각 auto, auto*인데 결과가 똑같이 나왔습니다.

이를 통해 컴파일러가 auto추론에서 포인터(*)를 자동으로 추론해주지만, 개발자가 명시적으로 포인터를 정의해줘도 된다는 것을 알았습니다!!

무조건 포인터를 쓸거라는 확신이 있으면 auto에 포인터를 명시해주는 습관을 들이는게 좋겠네용


4. 그외 잡다한 추론

이제 기본적인 추론 상황들은 다 본 것 같습니다...

좀 더 심화적으로 들어가보죠. 다음 상황들도 추론해 보세여 ㅇㅅㅇ

// 다음 변수의 타입을 직접 추론해보자 (안되는 것도 있다)
auto a1 { 1 };
auto a2 = { 1 };

auto b1 = 'abc';
auto b2 = "abc";

auto c1 = { 1,2,3,4 };
auto c2[4] = { 1,2,3,4 };

const int* d1 = 0;
auto       d2 = d1;

std::string e1 = "abc";
auto		e2 = e1.begin();

const int f1 = 1;
auto&&	  f2 = f1;

 

 

정답 및 간략한 설명

더보기

a1 = int

a2 = std::initializer_list<int>

 

b1 = int (잘못된 문자형 리터럴 사용. 멀티 캐릭터 리터럴(문자열 및 문자 리터럴(C++) | Microsoft Learn)로 간주해 int로 추론. but 옳지 않은 방식)

b2 = const char*

 

c1 =  std::initializer_list<int>

c2 = 컴파일 에러 (auto는 배열에 대해선 std::initializer_list<>로만 추론한다고 해요. 따라서 []배열선언이 불가능하다고 합니다?)

 

d2 = const int*

 

e2 = std::string::iterator

 

f2 = const int&


번외. 함수 포인터에 대한 추론 

void Func1(const char* _str)
{
	std::cout << _str << "ㅇㅅㅇ" << '\n';
}
int main()
{	
    // 함수 포인터를 쓸때 편하다!
	void (*funcPtr1)(const char*);
	auto funcPtr2 = Func1;  // = void (*funcPtr2)(const char*);
}

 

함수 포인터를 쓸때 정말 ㄹㅇ로 편해집니다? 


auto를 쓸 때 주의할 점

auto는 개발자가 제대로 모르고 남발하면 의도된 타입과 다르게 추론될 수 있다는 것을 알았는데요 ㅇㅅㅇ. 때문에 추론 규칙을 완벽히는 아니더라도 잘 숙지해서 쓰는 것도 중요합니다. 변수 위에 마우스 커서를 올리면 추론 값이 명시적으로 나오기 때문에 그걸 확인하는 것도 좋은 습관이 되겠습니다!

또한 auto를 쓰게 되면 코드 간결성은 늘어날 수 있지만, 코드 가독성을 해칠 수 있는데요 ㅇㅅㅇ. 명확한 자료형을 제시함으로써 코드를 읽을 때 어떤 값을 받는지 대충 예상할 수 있지만 auto를 쓰게 되면 확인하기가 힘들기 때문에 코드 가독성면에서 안좋다고 볼 수 있습니다...

마지막으로 auto는 컴파일 시간에 영향을 줍니다. 사실 뭐 엄청나게 컴파일 타임이 늘어나진 않지만, 타입이 복잡해질 경우 조금 영향을 미칠 수 있다고 하네요.


하 알아볼게 왤캐 많아 ㅇㅅㅇ

저는 개인적으로 auto를 비선호합니다(iterator정도는 가끔 씀). 사실 프로그래머라면 auto를 쓰지 않고도 어떤 타입을 써야되는지 알아야 할 필요가 있죠? 그럼에도 코드 간결성은 프로그래머에게 있어서 코스트라고 생각하기 때문에 나쁘게 생각하지는 않습니다. 적재적소에 잘 쓰면 좋은 키워드인건 맞다고 생각합니다 ㅇㅅㅇ

C++14에선 함수의 반환 값 추론도 있다고 하는데요? 그건 그만 알아보자.araboza

 

 

반응형

+ Recent posts