프로그래밍 지식/Effective Modern C++

Effective Modern C++, 항목 5 : 명시적 형식 선언보다는 auto를 선호하라

게임이 더 좋아 2022. 6. 18. 13:04
반응형
728x170

 

C++11 부터 auto가 생겨나면서 아래의 문제점들이 해결되었다.

  • 초기화되지 않는 변수를 컴파일러가 에러로 잡지 않는다.

=> auto 키워드로 변수를 선언하려면 무조건 초기화를 해야함.

int x;            // 문맥에 따라 초기화 안됨
auto x2;        // 오류! 초기치가 꼭 필요함
auto x3 = 0;    // 양호함

 

 

  • 값의 형식이 간단하지 않다.

=> auto 는 type deduction을 통해 타입을 줄여줌

template<typename It>
void dwim(It b, It e)
{
for (; b != e; ++b) {
  typename std::iterator_traits<It>::value_type
    curValue = *b;
  ...
}
}

 

template<typename It>
void dwim(It b, It e)
{
for (; b != e; ++b) {
  auto curValue = *b;
  ...
}
}

 

  • 클로저 형식의 지역 변수 선언이 불가능하다.

=> 클로저에서는 임시 변수로 선언되는 것임.

auto derefUPLess =
[](const std::unique_ptr<Widget>& p1,
   const std::unique_ptr<Widget>& p2)
{ return *p1 < *p2; };

c++14 부터 아래도 가능

auto derefUPLess =
[](const auto& p1,
   const auto& p2)
{ return *p1 < *p2; };

 

클로저란?

 

“람다”라는 것은 람다 표현식의 준말이고, 그저 표현식일 뿐이다.
그것은 단지 프로그램의 소스 코드에서만 존재한다. 런타임에서는 람다는 존재하지 않는다.

람다 표현식에 대한 런타임 결과오브젝트의 생성이다.
그러한 오브젝트를 클로져라고 한다.

 

우선 람다식의 예를 보자면

int main()
{
  // 람다식은 []로 시작해서
  //람다식에 들어갈 파라미터 설정 => int a, int b
  //그리고 화살표 =>   ->
  //리턴 데이터 타입 설정한다.  int { }
  // stack 영역은 람다의 실행 영역이다. => stack은 함수가 끝나면 반환되는 영역
  
  
  auto lambda = [](int a, int b) -> int { return a + b; };
  // 콘솔 출력
  cout << " 1 + 2 = " << lambda(1, 2) << endl;
  return 0;
}

 

C#의 람다와 비슷하다. 형태만 조금 다르지 비슷하다.

C#에서는 Action이나 Func 함수가 있지만 (이전 글 : [Unity, 유니티/Basic, 기본] - C#에서의 Func, Action [Unity])

C++에는 없다.  대신 auto가 있다.

이 auto함수는 타입 추론 방식이다. C#에서는 var타입과 같다.

=> var를 사용할 때도 클로져를 들어봤을 것이다.

(void*)와는 조금 타입의 차이가 있다.

void*는 할당된 객체의 메모리 주소라면 auto는 타입의 추론이다.

즉, 추론에 따라 바뀐다는 말이다.

하지만 해당  타입이 정해지면 변하지 않는다.

 

람다식은 사실 데이터 타입이 조금 복잡해서 변수로 선언할 때는 auto로 선언하고 사용한다.

즉, 람다식과 auto 타입은 같이 붙어다니는 타입이라고 볼 수 있다.

다시 아래 예를 보면,

auto f = [](int x, int y) { return fudgeFactor * (x + y); };

“=” 오른쪽에 있는 표현식이 람다이고
이 표현식으로 부터 생성된 런타임 오브젝트가 클로져이다.

위의 예에서 "f" 를 클로져라고 생각할 수도 있지만 그건 아니다.

f는 클로져의 복사본이다.

클로져를 복사하는 과정은 move로 최적화될 수 있지만 "f" 가 클로져가 아니라는 사실은 변하지 않는다.
실제 클로져 오브젝트는 임시 객제로 그 줄의 끝에서 파괴된다.

람다와 클로져의 차이는 정확하게 클래스와 클래스 인스턴스의 차이와 동일하다.
클래스는 오직 소스코드에서만 존재하고, 런타임에서는 존재하지 않는다.
런타임에서 존재하는 것은 클래스 타입의 오브젝트들이다.

클로져와 람다의 관계는 오브젝트와 클래스의 관계와 같다.
놀라울게 없는게, 각 람다 표현식은 컴파일 과정에서 고유한 클래스를 만들어내고
클래스 타입의 오브젝트(클로져)가 (런타임에서) 생성된다.

 

 

std::function과 auto (클로저를 담는)

  • std::function 은 함수 포인터 개념을 일반화한 것으로 함수 포인터 뿐만 아니라 함수처럼 호출 가능한 객체라면 무엇이든 가리킬 수 있다.
  • std::function 은 객체를 생성할 때 그것이 지칭할 함수의 형식을 반드시 지정해야 한다.
    std::function<bool(const std::unique_ptr<Widget>&,
                     const std::unique_ptr<Widget>&)>
    derefUPLess = [](const std::unique_ptr<Widget>& p1,
                     const std::unique_ptr<Widget>& p2)
                    { return *p1 < *p2; };
  • auto 로 선언된 그리고 클로저를 담는 변수는 그 클로저와 같은 형식이며, 따라서 그 클로저에 요구되는 만큼의 메모리만 사용한다.
  • 클로저를 담는 std::function으로 선언된 변수의 형식은 인스턴스이며, 그 크기는 임의의 주어진 서명에 대해 고정되어 있다. 따라서 크기가 요구된 클로저를 저장하기에 부족할 수 있으며, 힙 메모리를 할당해서 클로저를 저장한다.
  • 인라인화를 제한하고 간접 함수 호출을 산출하는 구현 세부사항 때문에, std::function 객체를 통해서 클로저를 호출하는 것은 거의 항상 auto로 선언된 객체를 통해 호출하는 것보다 느리다.

 

형식단축(type shortcut) 문제를 피할 수 있다.

  • 응용 프로그램을 32비트에서 64비트로 이식할 때 문제 해결에 용이하다.
    ex) std::vector<int> 의 size()의 공식적인 반환 형식은 std::vector<int>::size_type인데 이것이 32비트 에서는 32비트인 반면 64비트에서는 64 비트이다. 하지만 unsigned는 32비트로 동일하다.
    std::vector<int> v;
    unsigned sz = v.size();
    std::vector<int> v;
    auto sz = v.size();
  • 불필요한 변환을 막아준다.
    ex) std::unordered_map의 key는 const 형태이다. 따라서 아래와 같은 경우 컴파일러가 어떻게든 const std::string을 std::string으로 변환하려 한다. 이는 내부적으로 복잡한 작업이 수행됨을 뜻한다.
    std::unordered_map<std::string, int> m;
    for (const std::pair<std::string, int>& p: m)
    {
    ...
    }
    std::unordered_map<std::string, int> m;
    for (const auto& p: m)
    {
    ...
    }

가독성의 문제가 생길 수 있다. => 추론의 결과를 직관적으로 알 수 없음

  • 객체의 형식을 보여주는 IDE의 기능으로 완화될 수 있다.
  • 변수의 이름을 잘 지어두면 추상적인 파악만으로도 충분한 경우가 많다.

refactoring 이 수월해진다.

  • 어떤 함수가 int를 돌려주게 했는데, 나중에 long이 더 나은 선택이라는걸 알았다면 호출 지점을 모두 찾아 고쳐야 한다.

=> Auto를 이용하면 long으로 Type Deduction이 가능해짐

반응형
그리드형