CPP1

개발 공부 블로그·2024년 3월 1일
0

namespace

  • C언어는 동일한 이름의 함수를 2개 이상 만들 수 없다.
    그러나, C++ 에서는 namespace 를 활용하여 관련된 코드들을 묶어서 하나로 관리할 수 있다.

  • namespace Phone
    {
      namespace Iphone
      {
          void call()
          {
              std::cout<<"Iphone call"<<std::endl;
          }
          
          void text()
          {
          	  std::cout<<"Iphone text"<<std::endl;
          }
      }
      namespace Galaxy
      { 
          void call()
          {
              std::cout<<"Galaxy call"<<std::endl;
          }
          
          void text()
          {
          	  std::cout<<"Galaxy text"<<std::endl;
          }
      }
    }
    
  • namespace 요소 접근 방법

    1. 완전한 이름(Full name) 사용 (권장)
    • ex) Phone::Iphone::call();
    1. using 사용
      2-1) 특정 namespace 만 사용하는 경우
      using Phone::Iphone;
       call(); 
       text();
      // => Iphone namespace 의 call,text 함수 호출
      2-2) 특정 namespace 의 특정 함수만 사용하는 경우
      using Phone::Iphone::call;
       call();
       text(); // error
       // => Iphone namespace 의 call 함수 호출

namespace 의 핵심은 Full name 으로 사용하는 것. (프로젝트의 규모가 커질수록 전역공간에 using 을 사용하는 것은 충돌의 확률을 높인다.)

  • namespace 는 중첩이 가능하다. 위와 같이 Phone::Iphone 과 같이 Phone namespace 안에 Iphone namespace 가 중첩되어 있을 수 있다.

Uniform Init

  • C++11 이전에 초기화 방법은 각양 각색이다.
struct Point
{
	int x;
    int y;
}

int a = 10;
int arr[5]={1,2,3,4,5}
Point p(1,2);
  • (), {}, = 초기화 등 다양한 초기화가 타입별로 다르다. -> 헷깔릴 수 있다.

  • 그래서 등장한 것이 uniform initialization. 말 그대로 initialize 방법을 통일한 것.

struct Point
{
	int x;
    int y;
}

// direct initalization
int a{10};
int arr[5]{1,2,3,4,5}
Point p{1,2};

// copy initialization
int a={10};
int arr[5]={1,2,3,4,5}
Point p={1,2};

  • C++ 에서는 unifrom initialization 으로 초기화하는 것을 추천한다. 왜냐면 더 안전하고, 사용자가 편리하게 표기할 수 있기 때문.
int a = 7.1; // 캐스팅이되어 7로 a에 저장된다. 
int a{7.1} // 컴파일 에러가 발생한다. 즉 더 안전하다.

Default Paramter

  • C++ 에서는 default parameter 지정이 가능하다.

  • int add(int a, int b, int c = 0) 과 같이 맨 끝에서 부터 차례대로 지정이 가능.

    • 이유를 생각해보면 보통 add(1,2) 라는 함수를 호출했을 때, 개발자는 당연하게도 1과 2가 차례대로 a, b에 들어간다고 생각할 것이다. 이러한 가독성을 위해 파라미터의 끝에서부터 차례대로 default 값을 지정할 수 있다.
  • 함수의 선언부와 구현부가 나눠져 있다고 생각해보자.

int add(int a, int b = 0);

int main()
{
	cout<<add(1,2);
}

int add(int a, int b=0)
{
	return a+b;
}
  • 위와 같은 코드가 있다고 가정해보자. 이때 컴파일러의 동작을 살펴보면 코드를 순차적으로 읽으며 add 함수의 선언부에서 "아, add 함수는 두번째 파라미터에 default 값이 세팅되어 있구나"라고 생각할 것이다. 그리고 밑에 구현부를 보고는 "오잉? 두번째 파라미터에 default 값을 다시 정의하려고 그러네?" 라고 생각하여 컴파일 에러를 발생시킨다.

  • add(1) 을 호출하면 컴파일러가 파라미터를 하나만 보내는 것이 아니라, add(1,0) 으로 바꿔 보낸다.

Overloading

  • C++ 에서는 동일한 이름의 함수를 사용 가능하다. 단, 파라미터 타입 또는 파라미터 개수가 바껴야 한다.

    • return 타입만 다르게 하면 컴파일 에러 발생. 컴파일러 입장에서 add(1,2); 라는 함수 호출식을 보고 그에 맞는 함수를 찾아가야 되는데, int add(int, int) 와 double add(int, int) 가 있다면 어디로 가야할지 모르기 때문.
  • 이것이 편리한 이유는 사용자 입장에서는 동일한 함수처럼 생각을 할 수 있다. 예를 들어, add(1,0) 과 add(1.2, 2,1) 이 있다면 overloading 을 활용하여 두개의 서로다른 함수를 만들 수 있지만, 이 api 를 사용하는 사용자 입장에서는 동일한 함수라고 느껴질 것이다.

  • 주의 : defualt 파라미터가 있을 때 조심하자.

int add(int a, int b=0);
int add(int a); 
int main()
{
	add(1,2); // 정상적으로 호출된다.
    add(1); // ambiguous 하다며 컴파일 에러가 발생.
}
  • Function Overloading 의 원리
    • 그렇다면 C에서는 안되던 기능이 어떻게 C++ 에서는 가능한것일까?
      => C++ 에서는 name mangling 을 사용하기 때문이다.
    • name mangling 이란 컴파일러가 컴파일 시에 함수이름을 자기가 알아볼 수 있도록 변경하는 것.
      • 소스코드
      • 컴파일 후
    • 위에서 볼 수 있듯이 squareii, squared 와 같이 함수명과 파라미터를 섞어 변경한다.(gcc 기준) 따라서 함수 이름이 같아도 파라미터 타입이 다르거나 개수가 다른 것을 구분할 수 있는 것이다.
      • 컴파일러마다 mangling 규칙은 다르다. (이름이 다르다)
  • 그러나 C에서는 컴파일러가 mangling 을 하지 않아 컴파일 후에도 원래 이름 그대로 사용한다.

extern "C"

  • extern "C" 라는 것은 뭘까?
  • 다음 예시를 보자
// add.c
int add(int a, int b)
{
	return a+b;
}

// add.h
int add(int a, int b);

// main.cpp
#include "add.h"
int main()
{
	add(3, 4);
}

위 코드를 빌드하면 링킹 에러가 발생한다. 왜일까? 우리가 앞서 말했듯이 c++ 에서는 함수에서는 컴파일러가 name mangling 을 한다. 근데, main.cpp 파일에서는 add 함수를 _Z6addii 와 같은 형태로 name mangling 하여 함수를 찾을 것이다. 그런데, add.c 에서는 그냥 add 라는 함수로 컴파일이 될 것이다. 그래서, main.cpp 에서 add 함수를 찾을 수가 없다.
=> 이를 해결하기 위해 extern "C" 라는 것을 사용한다.

  • cpp 컴파일러가 extern "C" 를 보면 "아, 여기 있는 애들은 C로 되어있는 함수들이구나" 라고 생각하고, name mangling 을 하지 않는다.
// add.c
int add(int a, int b)
{
	return a+b;
}

// add.h
extern "C"{
int add(int a, int b);
}

// main.cpp
#include "add.h"
int main()
{
	add(3, 4);
}

위 코드를 보고 add.h 를 include 하는 main.cpp 에서는 "아, add 함수는 C로 되어있으니, name mangling 하지 말고, add 라는 함수로 찾으면 되겠구나" 라고 생각한다.

  • 주의점

    • extern "C" 는 cpp 컴파일러만 알 수 있는 문법이며, C 컴파일러는 모른다. 따라서, main.cpp 를 main.c 로 바꾸면 컴파일 에러가 발생한다. 이를 대비하여 cpp 컴파일러에 정의되어있는 __cplusplus 라는 매크로를 사용하자.
    // add.c
    int add(int a, int b)
    {
        return a+b;
    }
    
    // add.h
    #ifdef __cplusplus
    extern "C"{
    #endif
    
    int add(int a, int b);
    
    #ifdef __cplusplus
    }
    #endif
    
    // main.c
    #include "add.h"
    int main()
    {
        add(3, 4);
    }

이렇게 만들어 놓으면 main.c 가 되었다고 해도 __cplusplus 가 정의되어 있지 않으니 그냥 int add(int, int) 만 컴파일되고, cpp 로 컴파일 시에는 extern "C" 까지 포함이 되어 컴파일된다.

template

  • 앞서 배운 overloading 의 단점
    • add 함수의 로직이 변경되면 오버로딩되어 있는 여러 add 함수들을 다 바꿔줘야 한다.
    • 이는 template 기법으로 해결 가능
// overloading 사용 시
int add(int a, int b)
{
	return a+b;
}

double add(double a, double b)
{
	return a+b;
}

short add(short a, short b)
{
	return a+b;
}

// template 사용 시
template <class T>
T add(T a, T b)
{
	return a+b;
}
  • template 은 그 자체로 메모리에 올라가지 않는다! (실체를 갖지 않는다.)

  • template 을 실제로 사용해야 코드에 함수로서 메모리에 잡힌다. (실체가 생긴다 = instantiation)

    • 위에서 볼 수 있듯이 add(1,2), add(1.1, 2,1) 과 같이 실제로 템플릿을 사용해야 함수로서 메모리에 올라가게 된다.
  • template 사용법

    • add<int(type)>(1,2) : 이게 정식 표현이다.
    • add(1,2) : 이렇게 해도 컴파일러가 알아서 타입을 추론하여 int add 함수를 만들어준다.
    • add<...> : 실제 주소를 갖는다.
    • add : 실제 주소를 갖지 않는다.

inline 함수

  • 인라인 함수는 일반 함수와 다르게 기계어 자체를 치환하는 것.
inline int add(int a, int b)
{
	return a+b;
}


int main()
{
	int k = add(1,2);
    // => add가 inline 함수이므로 add(1,2)가 바로 a+b로 치환된다.
    // int k = a+b; 와 같다.
}
  • 어셈블리 레벨에서 함수 치환을 생각해보면,
    1. 그동안 작업하던 context(레지스터 값) 를 스택 포인터에 넣어주고,
    2. 파라미터를 컴파일러별로 약속된 레지스터에 넣어주고, (ex. mov r0, #1, mov r1, #2)
    3. 해당 함수의 주소로 jump 한다. (bl add)
  • 이에 반해 inline 함수는 위와 같은 과정을 생략하므로 오버헤드가 적고 성능이 빠르다.
  • 그러나 만약 add 함수가 엄청 긴 라인의 함수라면 add 가 불릴 때마다 치환을 해야하므로 코드 사이즈가 커진다.

template/inline

  • template 함수와 inline 함수를 사용할 때 주의해야 하는 점.
    • cpp 와 헤더파일로 분리해보자.
// math.h

inline double getPI();

template <class T>
T add(T a, T b);

int mul(int a, int b);

// math.cpp

inline double getPI()
{
	return 3.14;
}

template <class T>
T add(T a, T b)
{
	return a+b;
}

int mul(int a, int b)
{
	return a*b;
}

// main.cpp

#include "math.h"
int main()
{
	int addVal = add<int>(1,2);
    double PI = getPI();
    int mulVal = mul(1,2);
}

위과 같은 코드가 있다고 생각해보자. 저 코드는 정상적으로 빌드가 될까? -> no

  • inline함수 : inline 함수는 컴파일 시점에 컴파일러가 함수 호출 부분을 구현으로 바꿔줘야 한다. 근데, math.h 에서 선언만 되어있을 뿐 구현부를 컴파일러는 알 수가 없다. 이것을 알 수 있는 시점은 링크 시점이다. 따라서 컴파일 에러가 발생한다.

  • template 함수 : 앞에서 템플릿을 컴파일 했을 때 기계어를 봤지만, 템플릿 함수를 컴파일을 하면 함수가 생성된다. 즉, main.cpp 에서 add(1,2) 를 봤을 때 컴파일러는 컴파일 시점에 int add 함수를 만들어야 한다. 그러나 컴파일러는 구현부를 알 수가 없다. math.cpp 에 정의되어있으니깐. 따라서 에러가 발생한다.

  • 따라서 inline 함수와 template 함수는 헤더파일에 구현을 해줘야한다.

// math.h

inline double getPI()
{
	return 3.14;
}

template <class T>
T add(T a, T b)
{
	return a+b;
}

int mul(int a, int b);

// math.cpp

int mul(int a, int b)
{
	return a*b;
}

// main.cpp

#include "math.h"
int main()
{
	int addVal = add<int>(1,2);
    double PI = getPI();
    int mulVal = mul(1,2);
}

auto / decltype

  • auto (C++11)
    • 변수 선언 시 우변의 표현식을 조사하여 컴파일러가 타입을 결정한다.
    • 컴파일 타임에 타입이 결정되기 때문에, 오버헤드는 없다.
    • 데이터 타입이 복잡한 경우, 유용하게 사용가능. (그러나, auto 키워드로는 해당 변수의 타입을 우리가 알 수는 없기 때문에 가독성이 떨어질 수 있다.)
  • decltype(c++11)
    • ()안의 표현식으로 타입 결정
    • 템플릿에서 많이 사용된다.

suffix return

  • 후위 반환 타입 (C++11) : swift, haskell 등 많은 언어가 사용하는 기법.
  • 함수의 반환 타입을 뒷쪽에 정의한다.
int add(int a, int b)
{
	return a+b;
}

auto add(int a, int b) -> int
{
	return a+b;
}
  • 논리적으로 "a, b가 들어가서 int 값이 리턴된다" 이기 때문에 가독성이 높아진다.
  • 특히나 template 에서 필요한 경우가 많다.
template <class T>
T add(T a, T b)
{
	return a+b;
}

int main()
{
	add(1,2);
    add(1.1,2.1);
    add(3,7.7); // error
}

위와 같은 코드에서 add(3, 7.7) 은 에러가 발생한다. 그럼 어떻게 변경할까? class T1, class T2 로 받아보자. 그리고 반환 타입은 decltype을 써서 a+b의 값을 컴파일러가 추론하여 반환하도록 하자.

template <class T1, class T2>
decltype(a+b) add(T1 a, T2 b)
{
	return a+b;
}

int main()
{
	add(1,2);
    add(1.1,2.1);
    add(3,7.7); // error
}

근데 이렇게하면 에러가 발생한다. 왜냐면 a, b 가 선언되지 않은 상태에서 decltype(a+b) 를 했기 때문에.(컴파일러는 앞에서 뒤로 읽으니깐)
=> 이때 suffix return 사용

template <class T1, class T2>
auto add(T1 a, T2 b) -> decltype(a+b) 
{
	return a+b;
}

int main()
{
	add(1,2);
    add(1.1,2.1);
    add(3,7.7); // ok
}

delete keyword

  • delete 는 명시적으로 컴파일러에게 해당 함수는 사용하지 않는다는 것을 알려주는것.
int add(int a, int b)
{
	return a+b;
}

int main()
{
	add(1.2, 2.3);
}

위와 같이 하면 캐스팅이 되어 add(1,2) 로 넘어간다.

int add(int a, int b)
{
	return a+b;
}

double add(double, double) = delete;

int main()
{
	add(1.2, 2.3);
}

위와 같이 하면 add(1.2, 2.3) 을 해도 컴파일 에러가 발생한다.
이는 나중에 배울 class 에서 자세히 다룸.

reference

  • 레퍼런스는 이미 존재하는 변수에 별칭을 부여하는 것.
  • 가리키는 대상이 똑같아서 주소도 동일
  • const reference
    • 우리가 사용자 정의 타입을 파라미터로 보낼 때 만약 해당 함수 내에서 내부 값을 변경하지 않는다고 할 때 사용.
    • 근데 call by value 형태로 전달해도 값은 변경되지 않는다.
struct Point 
{
	int x;
    int y;
};
void print(Point p)
{
	cout<<p.x << p.y<<endl;
}
int main()
{
	Point p{1,2};
    print(p);
}
  • 그러나, print 함수로 전달되면서 복사본이 생성이 되므로 오버헤드가 발생한다. 이럴 때 const reference 방식으로 받아야한다. 오버헤드 없이 인자를 바꾸지 않겠다는 뜻.
struct Point 
{
	int x;
    int y;
};
void print(const Point& p)
{
	cout<<p.x << p.y<<endl;
}
int main()
{
	Point p{1,2};
    print(p);
}
  • 정리
    1. 인자의 값을 변경하는 경우: 포인터, 레퍼런스 둘 다 사용가능하나 C++ 에서는 레퍼런스를 더 많이 사용한다.
    2. 인자의 값을 변경안할때
      2-1) primitive type : int, double과 같은 primitive type 은 그냥 call by value 형태로 전달. 타입의 크기가 크지 않아 오버헤드가 거의 없고, 컴파일러 최적화도 지원이 잘됨.
      2-2) user defined type : struct, class 와 같은 user defined type 은 const reference 로 전달. 타입의 크기가 보통 크고 오버헤드가 높아서 const reference 로 전달하는 것이 유리하다.

reference 는 call by reference, return by reference 의 관점에서 중요하다. 즉, reference 는 return 을 하던, 파라미터로 전달하던 임시객체가 생성되지 않는다. 이는 오버헤드를 줄여주고, return 시에는 lvalue 로 사용이 가능하다는 장점이 있다.

0개의 댓글

관련 채용 정보