사람들이 goto
진술을 많이 싫어하는 것 같습니다 . 그래서 조금 정리해야 할 필요성을 느꼈습니다.
사람들이 갖는 '감정'은 goto
결국 코드에 대한 이해와 가능한 성능 영향에 대한 오해로 귀결됩니다. 질문에 대답하기 전에 먼저 컴파일 방법에 대해 자세히 설명하겠습니다.
우리 모두 알고 있듯이 C #은 IL로 컴파일 된 다음 SSA 컴파일러를 사용하여 어셈블러로 컴파일됩니다. 이 모든 것이 어떻게 작동하는지에 대해 약간의 통찰력을 제공하고 질문 자체에 답하려고 노력할 것입니다.
C #에서 IL로
먼저 C # 코드가 필요합니다. 간단하게 시작하자 :
foreach (var item in array)
{
// ...
break;
// ...
}
이 과정을 단계별로 수행하여 후드 아래에서 발생하는 일에 대한 좋은 아이디어를 제공합니다.
첫 번째 번역 :에서 foreach
동등한 for
루프로 (참고 : IDisposable에 대한 세부 정보를 얻고 싶지 않기 때문에 여기서 배열을 사용하고 있습니다.이 경우 IEnumerable도 사용해야합니다).
for (int i=0; i<array.Length; ++i)
{
var item = array[i];
// ...
break;
// ...
}
둘째 번역 다음 for
과 break
쉬운 동등한로 번역 :
int i=0;
while (i < array.Length)
{
var item = array[i];
// ...
break;
// ...
++i;
}
그리고 세 번째 번역 (IL 코드와 동일 함) : 분기를 변경 break
하고 while
분기합니다.
int i=0; // for initialization
startLoop:
if (i >= array.Length) // for condition
{
goto exitLoop;
}
var item = array[i];
// ...
goto exitLoop; // break
// ...
++i; // for post-expression
goto startLoop;
컴파일러는 단일 단계로 이러한 작업을 수행하지만 프로세스에 대한 통찰력을 제공합니다. C # 프로그램에서 발전한 IL 코드 는 마지막 C # 코드 의 리터럴 변환 입니다. https://dotnetfiddle.net/QaiLRz 에서 직접 확인할 수 있습니다. 보기 '클릭)
자, 여기서 관찰 한 것은 프로세스 중에 코드가 더 복잡해진다는 것입니다. 이것을 관찰하는 가장 쉬운 방법은 동일한 것을 인식하기 위해 점점 더 많은 코드가 필요하다는 것입니다. 또한 그 주장 수도 foreach
, for
, while
과 break
에 대해 실제로 짧은 손입니다 goto
부분적으로 사실이다.
IL에서 어셈블러로
.NET JIT 컴파일러는 SSA 컴파일러입니다. 여기서는 SSA 양식의 모든 세부 사항과 최적화 컴파일러를 만드는 방법에 대해 다루지 않을 것입니다. 너무 많지만 발생할 일에 대한 기본적인 이해를 줄 수 있습니다. 더 깊이 이해하려면 컴파일러 최적화에 대해 읽어 보는 것이 가장 좋습니다 (약간의 소개를 위해이 책을 좋아합니다 : http://ssabook.gforge.inria.fr/latest/book.pdf ) 및 LLVM (llvm.org) .
모든 최적화 컴파일러는 코드가 쉽고 예측 가능한 패턴을 따른다 는 사실에 의존 합니다 . FOR 루프의 경우 그래프 이론을 사용하여 분기를 분석 한 다음 분기의 cycli와 같은 항목 (예 : 뒤로 분기)을 최적화합니다.
그러나 이제 루프를 구현할 정방향 분기가 있습니다. 짐작 하셨겠지만, 이것은 실제로 JIT가 다음과 같이 고칠 첫 단계 중 하나입니다.
int i=0; // for initialization
if (i >= array.Length) // for condition
{
goto endOfLoop;
}
startLoop:
var item = array[i];
// ...
goto endOfLoop; // break
// ...
++i; // for post-expression
if (i >= array.Length) // for condition
{
goto startLoop;
}
endOfLoop:
// ...
보시다시피, 우리는 이제 작은 분기점 인 뒤로 분기합니다. 여기서 여전히 불쾌한 유일한 것은 우리의 break
진술 때문에 우리가 끝낸 지점입니다 . 어떤 경우에는 이것을 같은 방식으로 옮길 수 있지만 다른 경우에는 그대로 유지해야합니다.
그렇다면 왜 컴파일러가 이것을합니까? 루프를 풀면 벡터화 할 수 있습니다. 상수가 추가되었다는 것을 증명할 수도 있습니다. 이는 전체 루프가 얇은 공기로 사라질 수 있음을 의미합니다. 요약하면, 분기를 예측 가능하게하여 패턴을 예측 가능하게함으로써 루프에서 특정 조건이 유지되고 있음을 증명할 수 있습니다. 이는 JIT 최적화 중에 마술을 수행 할 수 있음을 의미합니다.
그러나 브랜치는 이러한 예측 가능한 멋진 패턴을 깨뜨리는 경향이 있으며, 이는 최적화 프로그램이므로 다소 싫어합니다. 깨고, 계속하고, 가십시오-그들은 모두 예측 가능한 패턴을 깨뜨 리려고하므로 실제로 '좋은'것은 아닙니다.
또한이 시점에서 단순한 foreach
것이 더 예측 가능 하다는 것을 깨달아야 goto
합니다. (1) 가독성과 (2) 옵티 마이저 관점에서 볼 때 더 나은 솔루션입니다.
언급해야 할 또 다른 사항은 레지스터를 변수에 할당하도록 컴파일러를 최적화하는 데 매우 관련이 있다는 것입니다 ( 레지스터 할당 이라는 프로세스 ). 아시다시피, CPU에는 레지스터 수가 한정되어 있으며 하드웨어에서 가장 빠른 메모리입니다. 가장 안쪽 루프에있는 코드에 사용 된 변수는 레지스터가 할당 될 가능성이 높지만 루프 외부의 변수는 덜 중요합니다 (이 코드가 적을 수 있기 때문에).
도움, 너무 많은 복잡성 ... 어떻게해야합니까?
결론은 항상 사용하는 언어 구조를 사용해야한다는 것입니다.이 구문은 일반적으로 컴파일러에 대해 예측 가능한 패턴을 빌드합니다. (: 특히 가능하면 이상한 가지 않도록하십시오 break
, continue
, goto
또는 return
아무것도의 중간에).
여기서 좋은 소식은 이러한 예측 가능한 패턴이 읽기 쉽고 (사람에게는), 쉽게 알아볼 수 있다는 것입니다 (컴파일러).
이러한 패턴 중 하나를 SESE라고하며 이는 단일 항목 단일 종료를 나타냅니다.
그리고 지금 우리는 실제 질문에 도달합니다.
다음과 같은 것이 있다고 상상해보십시오.
// a is a variable.
for (int i=0; i<100; ++i)
{
for (int j=0; j<100; ++j)
{
// ...
if (i*j > a)
{
// break everything
}
}
}
이것을 예측 가능한 패턴으로 만드는 가장 쉬운 방법은 단순히 if
완전히 제거하는 것입니다 .
int i, j;
for (i=0; i<100 && i*j <= a; ++i)
{
for (j=0; j<100 && i*j <= a; ++j)
{
// ...
}
}
다른 경우에는 방법을 두 가지 방법으로 나눌 수도 있습니다.
// Outer loop in method 1:
for (i=0; i<100 && processInner(i); ++i)
{
}
private bool processInner(int i)
{
int j;
for (j=0; j<100 && i*j <= a; ++j)
{
// ...
}
return i*j<=a;
}
임시 변수? 좋고 나쁘거나 못생긴가요?
루프 내에서 부울을 반환하기로 결정할 수도 있습니다 (하지만 SESE 양식을 개인적으로 선호합니다. 컴파일러가 그것을 보는 방식이므로 더 읽기 쉽다고 생각합니다).
어떤 사람들은 임시 변수를 사용하는 것이 더 깨끗하다고 생각하고 다음과 같은 해결책을 제안합니다.
bool more = true;
for (int i=0; i<100; ++i)
{
for (int j=0; j<100; ++j)
{
// ...
if (i*j > a) { more = false; break; } // yuck.
// ...
}
if (!more) { break; } // yuck.
// ...
}
// ...
나는 개인적으로이 접근법에 반대합니다. 코드가 어떻게 컴파일되는지 다시 살펴보십시오. 이제이 멋진 예측 가능한 패턴으로 이것이 무엇을하는지 생각해보십시오. 사진 가져와? 이해가 되세요?
맞아요, 철자를 쓰겠습니다. 일어날 일은 :
- 컴파일러는 모든 것을 분기로 작성합니다.
- 최적화 단계로서, 컴파일러는
more
제어 흐름에서만 사용되는 이상한 변수 를 제거하기 위해 데이터 흐름 분석을 수행합니다 .
- 성공하면 변수
more
가 프로그램에서 제거되고 분기 만 남습니다. 이 분기는 최적화되므로 내부 루프에서 단일 분기 만 가져옵니다.
- 실패하면 변수
more
는 가장 안쪽 루프에서 확실히 사용되므로 컴파일러가 최적화하지 않으면 레지스터에 할당 될 가능성이 높습니다 (귀중한 레지스터 메모리를 소비합니다).
요약하자면, 컴파일러의 옵티마이 저는 more
제어 흐름에만 사용되는 것을 알아내는 데 많은 어려움을 겪을 것이며 최상의 경우 시나리오 는 외부의 단일 분기로 변환합니다. 고리.
다시 말해, 가장 좋은 시나리오는 다음과 같은 결과를 낳을 것입니다.
for (int i=0; i<100; ++i)
{
for (int j=0; j<100; ++j)
{
// ...
if (i*j > a) { goto exitLoop; } // perhaps add a comment
// ...
}
// ...
}
exitLoop:
// ...
이것에 대한 나의 개인적인 의견은 매우 간단합니다. 이것이 우리가 의도 한 것이라면 컴파일러와 가독성 모두를 위해 세상을 더 쉽게 만들고 즉시 작성하십시오.
tl; dr :
결론 :
- 가능하면 for 루프에서 간단한 조건을 사용하십시오. 가능한 한 많이 사용하는 고급 언어 구성을 고수하십시오.
- 모든 것이 실패하고
goto
또는로 남아 있다면 bool more
전자를 선호하십시오.