함수에서 조기 반환의 효율성


97

이것은 경험이없는 프로그래머로서 자주 접하는 상황이며 특히 최적화하려는 야심 차고 속도 집약적 인 프로젝트에 대해 궁금합니다. C와 유사한 주요 언어 (C, objC, C ++, Java, C # 등) 및 일반적인 컴파일러의 경우이 두 함수가 효율적으로 실행됩니까? 컴파일 된 코드에 차이가 있습니까?

void foo1(bool flag)
{
    if (flag)
    {
        //Do stuff
        return;
    }

    //Do different stuff
}

void foo2(bool flag)
{
    if (flag)
    {
        //Do stuff
    }
    else
    {
        //Do different stuff
    }
}

기본적으로 일찍 break노래하거나 return노래 할 때 직접적인 효율성 보너스 / 페널티가 있습니까? 스택 프레임은 어떻게 관련됩니까? 최적화 된 특수 사례가 있습니까? 여기에 중대한 영향을 미칠 수있는 요소 (예 : 인라인 또는 "Do stuff"의 크기)가 있습니까?

저는 항상 사소한 최적화에 비해 가독성 향상을 옹호하지만 (매개 변수 유효성 검사를 통해 foo1을 많이 봅니다), 이것은 너무 자주 발생하여 모든 걱정을 한 번에 모두 제쳐두고 싶습니다.

그리고 저는 조기 최적화의 함정을 알고 있습니다 ... 으, 그것은 고통스러운 기억입니다.

편집 : 나는 대답을 수락했지만 EJP의 대답은 a의 사용 return이 실제로 무시할만한 이유를 매우 간결하게 설명합니다 (어셈블리 return에서 함수 끝까지 '분기'를 생성하며 이는 매우 빠릅니다. 분기는 PC 레지스터를 변경하고 모두 있기 때문에 또한 캐시와 파이프 라인, 꽤 소문자입니다.) 특히이 경우에 대한 영향을 미칠 수있는, 말 그대로 차이가 없습니다 if/else와이 return함수의 마지막에 같은 지점을 만들 수 있습니다.


22
나는 그런 종류의 것들이 성능에 눈에 띄는 영향을 미칠 것이라고 생각하지 않습니다. 작은 테스트를 작성하고 자신을보십시오. 이모 첫 번째 변형은 readablitiy 향상 불필요한 중첩하지 않기 때문에 더
SirVaulterScoff

10
@SirVaulterScott, 두 경우가 어떤 식 으로든 대칭이 아닌 경우 동일한 수준의 들여 쓰기로 대칭을 이끌어 내고 싶을 것입니다.
luqui

3
SirVaulterScoff : 불필요한 중첩 감소를위한 +1
fjdumont

11
가독성 >>> 마이크로 최적화. 이것을 유지하는 웻웨어에게 더 의미가있는 방법으로하십시오. 기계 코드 수준에서이 두 구조는 상당히 멍청한 컴파일러에 공급 될 때도 동일합니다. 최적화 컴파일러는 둘 사이의 속도 이점을 지울 것입니다.
SplinterReality

12
이와 같은 것에 대해 걱정하여 "속도 집약적 인"프로젝트를 최적화하지 마십시오. 앱을 프로파일 링하여 실제로 느린 위치를 찾으십시오. 작동을 마쳤을 때 실제로 너무 느린 경우입니다. 실제로 속도를 늦추는 것이 무엇인지 짐작할 수는 거의 없습니다.
blueshift

답변:


92

전혀 차이가 없습니다.

=====> cat test_return.cpp
extern void something();
extern void something2();

void test(bool b)
{
    if(b)
    {
        something();
    }
    else
        something2();
}
=====> cat test_return2.cpp
extern void something();
extern void something2();

void test(bool b)
{
    if(b)
    {
        something();
        return;
    }
    something2();
}
=====> rm -f test_return.s test_return2.s
=====> g++ -S test_return.cpp 
=====> g++ -S test_return2.cpp 
=====> diff test_return.s test_return2.s
=====> rm -f test_return.s test_return2.s
=====> clang++ -S test_return.cpp 
=====> clang++ -S test_return2.cpp 
=====> diff test_return.s test_return2.s
=====> 

두 개의 컴파일러에서 최적화 없이도 생성 된 코드에 차이가 없음을 의미합니다.


59
또는 더 나은 방법 : 두 버전에 대해 동일한 코드를 생성하는 특정 컴파일러 버전이 하나 이상 있습니다.
UncleZeiv 2011 년

11
@UncleZeiv-대부분의 컴파일러는 소스를 실행 흐름 그래프 모델로 변환합니다. 이 두 가지 예에 대해 의미있게 다른 흐름 그래프를 제공하는 정상적인 구현을 상상하기는 어렵습니다 . 당신이 볼 수있는 유일한 차이점은 두 가지 다른 작업이 스왑된다는 것입니다. 그리고 심지어는 분기 예측을 최적화하기 위해 많은 구현에서 또는 플랫폼이 선호하는 순서를 결정하는 다른 문제에 대해 실행 취소 될 수 있습니다.
Steve314

6
@ Steve314, 확실히, 나는 단지 nitpicking이었다 :)
UncleZeiv

@UncleZeiv : clang에서도 동일한 결과를 테스트했습니다.
Dani

이해가 안 돼요. something()항상 실행되는 것이 분명해 보입니다 . 원래의 질문에, 영업 이익했다 Do stuffDo diffferent stuff플래그에 따라 달라집니다. 생성 된 코드가 동일 할 것이라고 생각하지 않습니다.
Luc M

65

짧은 대답은 차이가 없다는 것입니다. 자신에게 호의를 베풀고 이것에 대해 걱정하지 마십시오. 최적화 컴파일러는 거의 항상 당신보다 똑똑합니다.

가독성과 유지 보수성에 집중하십시오.

어떤 일이 발생하는지 확인하려면 최적화를 사용하여 빌드하고 어셈블러 출력을 살펴보십시오.


8
@Philip : 그리고 다른 사람들에게도 호의를 베풀고 이것에 대해 걱정하지 마십시오. 당신이 작성한 코드는 다른 사람들도 읽고 유지 관리 할 것입니다. 항상 쓰기 코드는 가능한 한 이해하기 쉬운으로합니다.
hlovdal

8
옵티마이 저는 당신보다 똑똑하지 않습니다 !!! 그들은 영향이 그다지 중요하지 않은 부분을 결정하는 데 더 빠릅니다. 정말로 중요한 곳에서는 컴파일러보다 최적화를 더 잘 경험할 수있을 것입니다.
요하네스

10
@johannes 동의하지 않습니다. 컴파일러는 더 나은 알고리즘을 위해 알고리즘을 변경하지 않지만, 최대 파이프 라인 효율성을 달성하기 위해 명령을 재정렬하는 놀라운 작업을 수행하고, 숙련 된 프로그래머조차도 결정할 수없는 루프 (핵분열, 융합 등)에 대한 사소한 작업을 수행합니다. 그가 CPU 아키텍처에 대한 친밀한 지식이 없다면 선험적으로 더 나은 것이 무엇입니까?
포트란

3
@johannes-이 질문에 대해서는 그렇게 생각할 수 있습니다. 또한, 일반적으로, 당신은 할 수 있습니다 가끔 몇 가지 특별한 경우에 더 나은 컴파일러보다 더 최적화 할 수 있지만 요즘은 전문 지식의 공정한 비트 소요 - 일반적인 경우는 옵티마이 당신이 생각할 수있는 대부분의 최적화를 적용하고 그렇게이다 몇 가지 특별한 경우가 아니라 체계적으로. 이 질문에 대해 컴파일러는 아마도 양식에 대해 정확히 동일한 실행 흐름 그래프를 구성 할 것입니다 . 더 나은 알고리즘을 선택하는 것은 인간의 일이지만 코드 수준 최적화는 거의 항상 시간 낭비입니다.
Steve314

4
나는 이것에 동의하고 동의하지 않습니다. 컴파일러가 무언가가 다른 것과 동등하다는 것을 알 수없는 경우가 있습니다. Uneeded 가지가 실제로 상처를 입을 수 있는 x = <some number>것보다 더 빠른 경우가 많다는 것을 알고 계셨습니까? if(<would've changed>) x = <some number>반면에 이것이 극도로 집약적 인 작업의 주요 루프 내에 있지 않는 한 나는 그것에 대해 걱정하지 않을 것입니다.
user606723

28

흥미로운 답변 : (지금까지) 모두 동의하지만, 지금까지 완전히 무시 된이 질문에 대한 가능한 함축적 의미가 있습니다.

위의 간단한 예를 리소스 할당으로 확장 한 다음 잠재적 인 리소스 해제로 오류 검사를 수행하면 그림이 변경 될 수 있습니다.

초보자가 취할 수 있는 순진한 접근 방식을 고려하십시오 .

int func(..some parameters...) {
  res_a a = allocate_resource_a();
  if (!a) {
    return 1;
  }
  res_b b = allocate_resource_b();
  if (!b) {
    free_resource_a(a);
    return 2;
  }
  res_c c = allocate_resource_c();
  if (!c) {
    free_resource_b(b);
    free_resource_a(a);
    return 3;
  }

  do_work();

  free_resource_c(c);
  free_resource_b(b);
  free_resource_a(a);

  return 0;
}

위의 내용은 조기 복귀 스타일의 극단적 인 버전을 나타냅니다. 코드가 복잡 해짐에 따라 시간이 지남에 따라 코드가 매우 반복적이고 유지 관리가 불가능 해집니다. 요즘 사람들은 예외 처리 를 사용 하여이를 포착 할 수 있습니다 .

int func(..some parameters...) {
  res_a a;
  res_b b;
  res_c c;

  try {
    a = allocate_resource_a(); # throws ExceptionResA
    b = allocate_resource_b(); # throws ExceptionResB
    c = allocate_resource_c(); # throws ExceptionResC
    do_work();
  }  
  catch (ExceptionBase e) {
    # Could use type of e here to distinguish and
    # use different catch phrases here
    # class ExceptionBase must be base class of ExceptionResA/B/C
    if (c) free_resource_c(c);
    if (b) free_resource_b(b);
    if (a) free_resource_a(a);
    throw e
  }
  return 0;
}

Philip은 아래의 goto 예제를 살펴본 후 위의 캐치 블록 내부에 끊김없는 스위치 / 케이스 를 사용할 것을 제안했습니다 . 하나는 switch (typeof (e)) 다음 free_resourcex()호출을 통과 할 수 있지만 이것은 사소한 것이 아니며 디자인 고려가 필요합니다 . 그리고 끊김없는 스위치 / 케이스는 아래에 데이지 체인 레이블이있는 goto와 똑같다는 것을 기억하십시오.

Mark B가 지적했듯이 C ++에서는 Resource Aquisition is Initialization 원칙, 간단히 말해서 RAII 를 따르는 것이 좋은 스타일로 간주됩니다 . 개념의 요점은 개체 인스턴스화를 사용하여 리소스를 획득하는 것입니다. 그런 다음 개체가 범위를 벗어나 해당 소멸자가 호출되는 즉시 리소스가 자동으로 해제됩니다. 상호 의존적 인 리소스의 경우 올바른 할당 해제 순서를 보장하고 모든 소멸자에 필요한 데이터를 사용할 수 있도록 개체 유형을 설계하기 위해 특별한주의를 기울여야합니다.

또는 사전 예외 일에 다음을 수행 할 수 있습니다.

int func(..some parameters...) {
  res_a a = allocate_resource_a();
  res_b b = allocate_resource_b();
  res_c c = allocate_resource_c();
  if (a && b && c) {   
    do_work();
  }  
  if (c) free_resource_c(c);
  if (b) free_resource_b(b);
  if (a) free_resource_a(a);

  return 0;
}

그러나이 지나치게 단순화 된 예제에는 몇 가지 단점이 있습니다. 할당 된 리소스가 서로 의존하지 않는 경우에만 사용할 수 있습니다 (예 : 메모리를 할당 한 다음 파일 핸들을 연 다음 핸들에서 메모리로 데이터를 읽는 데 사용할 수 없음). ), 그리고 반환 값으로 구별 가능한 개별 오류 코드를 제공하지 않습니다.

코드를 빠르게 (!) 유지하기 위해 간결하고 쉽게 읽을 수 있고 확장 할 수있는 Linus Torvalds는 악명 높은 goto 를 절대적으로 합당한 방식으로 사용하더라도 리소스를 다루는 커널 코드에 대해 다른 스타일을 적용했습니다 .

int func(..some parameters...) {
  res_a a;
  res_b b;
  res_c c;

  a = allocate_resource_a() || goto error_a;
  b = allocate_resource_b() || goto error_b;
  c = allocate_resource_c() || goto error_c;

  do_work();

error_c:
  free_resource_c(c);
error_b:
  free_resource_b(b);
error_a:
  free_resource_a(a);

  return 0;
}

커널 메일 링리스트에 대한 논의의 요점은 goto 문보다 "선호되는"대부분의 언어 기능이 거대한, 트리와 같은 if / else, 예외 처리기, 루프 / 중단 / 계속 문 등과 같은 암시 적 gotos라는 것입니다. 그리고 위의 예에서 goto는 작은 거리 만 점프하고 명확한 레이블을 가지고 있으며 오류 조건을 추적하기 위해 다른 코드를 제거하기 때문에 괜찮은 것으로 간주됩니다. 이 질문은 여기서 stackoverflow에서 논의되었습니다 .

그러나 마지막 예제에서 누락 된 것은 오류 코드를 반환하는 좋은 방법입니다. result_code++free_resource_x()호출 후에 를 추가하고 해당 코드를 반환 하려고 생각 했지만 이로 인해 위 코딩 스타일의 속도 향상이 일부 상쇄됩니다. 그리고 성공한 경우 0을 반환하기가 어렵습니다. 어쩌면 나는 상상력이 부족할지도 모릅니다 ;-)

그래서, 예, 저는 조기 수익을 코딩하는 문제에 큰 차이가 있다고 생각합니다. 그러나 컴파일러를 위해 재구성하고 최적화하는 것이 더 어렵거나 불가능한 더 복잡한 코드에서만 분명하다고 생각합니다. 일반적으로 자원 할당이 시작되면 일반적으로 발생합니다.


1
와, 정말 흥미 롭군요. 순진한 접근 방식의 유지 불가능 함을 확실히 감사 할 수 있습니다. 예외 처리가 특정 경우에 어떻게 개선됩니까? 오류 코드에 catch끊김없는 switch문을 포함하는 것처럼 ?
Philip Guin 2011 년

@Philip 기본 예외 처리 예제가 추가되었습니다. 고토 만 실패 가능성이 있습니다. 제안한 switch (typeof (e))는 도움이 될 수 있지만 사소한 것은 아니며 설계 고려가 필요합니다 . 끊김없는 스위치 / 케이스는 데이지 체인 레이블이있는 goto와 똑같다는 것을 기억하십시오 ;-)
cfi

+1 이것은 C / C ++ (또는 메모리를 수동으로 해제해야하는 모든 언어)에 대한 정답입니다. 개인적으로 저는 다중 레이블 버전이 마음에 들지 않습니다. 이전 회사에서는 항상 "고토 핀"이었습니다 (프랑스 회사였습니다). fin에서 우리는 모든 메모리를 할당 해제했고, 이것이 코드 리뷰를 통과하는 유일한 goto 사용이었습니다.
Kip

1
C ++에서는 이러한 접근 방식을 수행하지 않지만 RAII를 사용하여 리소스가 제대로 정리되었는지 확인합니다.
Mark B

12

이것이 그다지 답은 아니지만 프로덕션 컴파일러는 당신보다 최적화에 훨씬 더 잘할 것입니다. 나는 이러한 종류의 최적화보다 가독성과 유지 보수성을 선호합니다.


9

이것에 대해 구체적 return으로 말하면는 메소드의 끝 부분으로 컴파일됩니다. 여기에는 RET명령어가 있거나 무엇이든있을 수 있습니다. 생략하면 블록 else의 끝 부분이 브랜치로 컴파일됩니다 else. 따라서이 특정 경우에는 아무런 차이가 없음을 알 수 있습니다.


잡았다. 나는 실제로 이것이 내 질문에 매우 간결하게 대답한다고 생각합니다. 나는 말 그대로 레지스터 추가 일 뿐이라고 생각한다. 꽤 무시할 만하다. (아마도 시스템 프로그래밍을하고 있지 않는 한 ...) 나는 이것을 영광스럽게 언급 할 것이다.
Philip Guin 2011 년

@Philip 어떤 레지스터 추가? 경로에 추가 지시가 전혀 없습니다.
Marquis of Lorne

둘 다 레지스터가 추가되었을 것입니다. 그게 다 어셈블리 브랜치입니다. 프로그램 카운터에 추가 하시겠습니까? 나는 여기서 틀릴 수 있습니다.
Philip Guin 2011 년

1
@Philip 아니요, 어셈블리 브랜치는 어셈블리 브랜치입니다. 그것은 물론 PC에 영향을 미치지 않습니다하지만 완전히 다시로드 될 수있다, 또한 파이프 라인 WRT 프로세서의 부작용, 캐시 등이 있습니다
론의 후작

4

특정 컴파일러와 시스템에 대해 컴파일 된 코드에 차이가 있는지 정말로 알고 싶다면 어셈블리를 직접 컴파일하고 살펴 봐야합니다.

그러나 큰 계획에서 컴파일러가 미세 조정보다 더 잘 최적화 할 수 있다는 것은 거의 확실하며, 그렇게 할 수 없더라도 실제로 프로그램 성능에 문제가되지는 않습니다.

대신, 사람이 읽고 유지 관리 할 수있는 가장 명확한 방법으로 코드를 작성하고 컴파일러가 최선을 다하도록하십시오. 소스에서 가능한 최상의 어셈블리를 생성하십시오.


4

귀하의 예에서 수익이 눈에.니다. 반환이 // 다른 일이 발생하는 페이지 위 / 아래 페이지 일 때 디버깅하는 사람은 어떻게됩니까? 더 많은 코드가있을 때 찾기 /보기가 훨씬 더 어렵습니다.

void foo1(bool flag)
{
    if (flag)
    {
        //Do stuff
        return;
    }

    //Do different stuff
}

void foo2(bool flag)
{
    if (flag)
    {
        //Do stuff
    }
    else
    {
        //Do different stuff
    }
}

물론 함수는 한 페이지 (또는 두 페이지)를 넘지 않아야합니다. 그러나 디버깅 측면은 아직 다른 답변에서 다루지 않았습니다. 요점을 알았어!
cfi

3

나는 blueshift에 강력하게 동의합니다 : 가독성과 유지 보수를 먼저!. 그러나 정말로 걱정이된다면 (또는 컴파일러가 무엇을하고 있는지 배우고 싶다면, 장기적으로는 확실히 좋은 생각입니다.)

이것은 디 컴파일러를 사용하거나 낮은 수준의 컴파일러 출력 (예 : 어셈블리 lanuage)을 보는 것을 의미합니다. C # 또는 모든 .Net 언어에서 여기에 설명도구 는 필요한 것을 제공합니다.

그러나 직접 관찰했듯이 이것은 아마도 조기 최적화 일 것입니다.


1

에서 애자일 소프트웨어 장인의 핸드북 : 클린 코드

플래그 인수는 추악합니다. 부울을 함수에 전달하는 것은 정말 끔찍한 방법입니다. 이 함수가 한 가지 이상을 수행한다고 큰 소리로 선언하면서 메서드의 서명을 즉시 복잡하게 만듭니다. 플래그가 참이면 한 가지 일을하고 플래그가 거짓이면 다른 일을합니다!

foo(true);

코드에서 독자가 함수로 이동하고 foo (boolean flag)를 읽는 데 시간을 낭비하게합니다.

더 나은 구조화 된 코드 기반은 코드를 최적화 할 수있는 더 나은 기회를 제공합니다.


저는 이것을 예로 사용하고 있습니다. 함수에 전달되는 것은 int, double, 클래스 일 수 있습니다. 이름을 지정하면 문제의 핵심이 아닙니다.
Philip Guin 2011 년

당신이 물은 질문은 함수 내부에서 스위치를 수행하는 것에 관한 것입니다. 대부분의 경우 코드 냄새입니다. 그것은 여러 가지 방법으로 달성 될 수 있으며 독자는 전체 기능을 읽을 필요가 없습니다. foo (28)가 무엇을 의미 하는가?
위안

0

한 가지 생각은 (현재 제안한 지식인을 기억할 수 없음) 모든 함수는 코드를 더 쉽게 읽고 디버깅 할 수 있도록 구조적 관점에서 하나의 리턴 포인트 만 가져야한다는 것입니다. 그것은 종교적 토론을 프로그래밍하는 데 더 적합하다고 생각합니다.

이 규칙을 위반하는 기능이 종료되는시기와 방법을 제어해야하는 한 가지 기술적 이유는 실시간 애플리케이션을 코딩 할 때 기능을 통한 모든 제어 경로가 동일한 수의 클럭 사이클을 사용하도록하려는 경우입니다.


어, 나는 그것이 정리와 관련이 있다고 생각했습니다 (특히 C로 코딩 할 때).
Thomas Eding 2011 년

아니요, 스택을 반환하는 한 메서드를 어디에서 남겨 두든 상관 없습니다 (이것이 "정리"된 모든 것입니다).
MartyTPS

-4

이 질문을 제기 해 주셔서 감사합니다. 일찍 돌아올 때는 항상 지점을 사용해야합니다. 왜 거기서 멈추나요? 가능한 한 모든 기능을 하나로 병합하십시오 (최소한 가능한 한 많이). 재귀가 없으면 가능합니다. 결국, 당신은 하나의 거대한 주요 기능을 갖게 될 것입니다. 그러나 이것이 당신이 필요로하는 / 원하는 것입니다. 나중에 식별자 이름을 최대한 짧게 변경하십시오. 이렇게하면 코드가 실행될 때 이름을 읽는 데 소요되는 시간이 줄어 듭니다. 다음은 ...


3
농담이라고 말할 수는 있지만 무서운 것은 어떤 사람들이 당신의 조언을 진지하게 받아 들일 수도 있다는 것입니다!
Daniel Pryden 2011 년

Daniel과 동의하십시오. 내가 냉소주의를 좋아하는만큼 기술 문서, 백서 및 SO와 같은 Q & A 사이트에서 사용해서는 안됩니다.
cfi

1
-1은 냉소적 인 대답이며 초보자가 반드시 알아볼 수있는 것은 아닙니다.
Johan Bezem
당사 사이트를 사용함과 동시에 당사의 쿠키 정책개인정보 보호정책을 읽고 이해하였음을 인정하는 것으로 간주합니다.
Licensed under cc by-sa 3.0 with attribution required.