C 코드 최적화

CHOI·2021년 7월 30일
1

C 언어

목록 보기
28/28
post-thumbnail

원본 : https://www.codeproject.com/Articles/6154/Writing-Efficient-C-and-C-Code-Optimization

번역본 : https://www.joinc.co.kr/w/Site/C/Documents/COptimization

공부한 사이트 : https://modoocode.com/129

우리의 컴퓨터는 무한정으로 빠르지 않다. 따라서 동일한 작업이라고 어떠한 방식으로 실행 시키느냐에 따라서 그 속도 차이가 매우 크다. 우리는 언제나 코드를 만들때 다음과 같은 고민을 해야한다.

어떻게 하면 이 작업을 가장 빠르게 할 수 있도록 코드를 만들까??

똑같은 일을 하더라고 더 빠르게 수행할 수 있도록 코드를 짜는 행위를 "코드 최적화" 라고 한다.

들어가기 앞서

프로그램 최적화 작업은 매우 어려운 작업이다. 현대에 컴파일러는 아마도 우리보다 최적화 작업을 더 잘할 것이다. (그래서 그냥 놔두는 것이 더 빠른 작업이 되는 경우가 많다.)

필자가 하고 싶은 말은 최적화 작업에 너무 맹신하지말고 "아, 이러한 방식으로도 생각해볼 수 있구나" 정도로 생각하는 것이 제일 좋을 것 같다.

또한 최적화 작업을 하였다고 하여도 실제로 성능이 향상 될 수 있는지는 아무도 모른다. 프로그램의 작동 속도에 영향을 줄 수 있는 요소가 매우 많기 때문이다. 따라서 우리는 실제 향상 속도를 언제나 테스트 해보는 것이 중요한데, 이러한 작업을 프로파일링(profiling) 이라고 한다.

산술 연산 관련

부동 소수점 (float, double) 는 되도록 사용하지 말자

예전에 부동 소주점에 관해서 배운 적이 있다. 그 때 강좌를 잘 봤으면 알 수 있겠지만 부동 소수점은 매우매우 복잡한 구조로 이루어져 있다. 단순히 2 진수만 사용하던 char, int 와 달리 부동 소수점은 특정한 규격이 정해져 있기 때문에 매우 복잡하다.

따라서 부동 소수점을 가지고 하는 연산 또한 매우 복잡하고 느릴 수 밖에 없다. 따라서 반드시 부동 소수점을 사용해야 하는 경우가 아니라면 사용하지 않는 것을 권장한다. 반드시 부동 소수점을 사용해야 하는 경우라 하면 소수점 몇 째 자리까지의 정밀도가 요구되는 경우나 매우 큰 수를 다룰 때이다. 만일 소수점 둘째 자리나 첫째 자리 정도의 정밀도를 요구하면 단순히 그 수에 x 10, x 100 을 하여 정수 자료형으로 다루는 것이 오히려 좋다.

나눗셈을 피하라 - 1

아래는 초를 증가시키는 함수이다.

int inc_second(int second) { return (++second) % 60; }

초의 범위는 0 ~ 59 이다. 따라서 만일 60 을 넘었다면 위와 같이 60 으로 나눈 나머지를 구하면 된다. 그런데 문제점은 나눗셈이 매우매우 느린 작업이라는 것이다.

다른 덧셈 , 뺄셈에 비해 몇 배 가까이 느린 작업이기 때문에 엄청난 시간의 손해이다. 만일 우리가 second 가 60보다 커질 일이 없다는 것을 알고 있다면 굳이 60 으로 나눌 필요 없이 if 문을 사용하여 60일 때만 0을 리턴하도록 해주면 되는 것이다. 왜냐하면 if 문은 나눗셈보다 훨씬 빠르게 처리 되기 때문이다.

int inc_second(int second) {
  ++second;
  if (second >= 60) return 0;
  return second;
}

따라서 위와 같이 해주면 시간을 훨씬 아낄 수 있다.

한 가지 집고 넘어갈 점은 위 코드는 분기

점 (if ) 를 도입했다는 점이다. 때로는 분기점이 속도를 저하 시킬 수 있다.

왜냐하면 CPU 의 경우 명령어 실행 속도 향상을 위하여 "파이프라이닝" 이라는 작업을 수행합니다. 쉽게 말하자면 다음에 실행할 명령어를 이전 명령어 실행이 끝나기 이전에 미리 실행하는 것과 비슷하다고 보면 된다.

문제점은 분기문이 있을 경우 다음에 실행할 명령어가 무엇인지 모른다는 것이다. 위 경우 second >= 60 이면 return 0; 명령을 수행해야 하고 아니면 return second; 명령을 수행한다는 점이다.

그렇다면 CPU 는 second >= 60 이 끝날 때 까지 기다릴까? 아니다. 이전 추세를 보아서 대충 참일지 거짓일지를 예측 한 다음에 다음에 올 명령어를 실행하게 된다. 이렇게 분기문을 예측하는 것을 분기 예측(branch prediction) 이라고 한다.

예측이 맞았다면 기분 좋게 쭉쭉 나아갈 수 있지만 만약에 예측이 틀렸다면 여태까지 미리 작업한 것을 모두 버리고원래 실행해야 했던 명령어를 다시 실행하게 된다. Intel Skylake CPU 의 경우 해당 패널티가 20 cycle 정도 된다. 참고로 정수 나눗셈 연산(DIV)의 경우 10 cycle 정도 필요하고, 덧셈의 경우 1 cycle 에 끝나게 된다.(따라서 나눗셈이 덧셈보다 10배 느리다.)

  • cycle

    Cycle 이란 쉽게 생각하면 CPU 에서 한 가지 작업을 수행하는데 걸리는 시간 단위라 보시면 됩니다. 만약에 내 CPU 가 4Ghz 라면 1초에 40 억 cycle 어치의 연산을 수행할 수 있습니다

따라서 분기 예측 정확도를 50% 이상으로만 할 수 있다면 위와 같이 코드를 바꾸는 것이 효율적으로 최적화 되었다고 볼 수 있다. 다행이도 CPU 의 분기 예측기는 꽤나 똑똑해서, 이전 분기 결과에 추세 를 바탕으로 예측하게 된다. 우리가 만든 inc_second 함수의 경우 대부분의 경우가 second >= 60 이 참일 경우가 없기 때문에(확률상 1/60 이다), 분기 예측 확률이 꽤나 높을 것이다.

물론 실제 프로그램에서 inc_second 가 어떻게 사용될지는 아무도 모른다. 따라서 반드시 테스트를 통해 실제 향상이 있는지를 확인해보는 것이 좋다.

나눗셈을 피하라 - 2

대부분의 현대 컴파일러들은 이 작업을 알아서 최적화 해준다.

앞서 말했듯이 나눗셈은 시간이 매우 오래 걸리는 작업이다. 그런데 놀랍게도 2 의 멱수들(2, 4, 8, 16 ,32..)로 나눌 때에는 굳이 나눗셈을 사용하지 않고 매우 간단하게 처리할 수 있는 방법이 있다. 바로 '쉬프트' 연산을 사용하는 것이다. 쉬프트 연산은 컴퓨터 연산중에서도 가장 빠른 연산이므로 이를 잘 사용한다면 시간을 엄청나게 절약할 수 있다.

2의 멱수들을 2진수로 표현해 보면 10, 100, 1000 등이 될 것이다. 그럼 감이 올 수 있다. 우리가 만일 10진수로 생각할 때 7865 를 100 으로 나누면 몫이 얼마나 될까? 우리는 별로 고민하지 않고 78 이라고 말할 수 있다. 왜냐하면 단순히 뒤에 두 자리를 버리면 되기 때문이다. 2진수도 마찬가지이다. 11101010 을 1000으로 나눈 몫은 얼마일까? 이는 단순히 뒤에 세 자리를 버리면 되는 것이다. 따라서 11101 이 된다.

이 아이디어를 이용하여 1000 (이진수) 로 나눌 때 에는 수를 오른쪽으로 3 칸 쉬프트 해버리면 된다. 즉, 오른쪽으로 3 칸 밀어버리는 것이다. 아래 예제는 32로 나누는 것이다. 32 는 2 의 5 승 이므로 오른쪽으로 5 칸 쉬프트 하면 된다.

#include <stdio.h>
int main() {
  int i;
  printf("정수를 입력하세요 : ");
  scanf("%d", &i);

  printf("%d 를 32 로 나누면 : %d \n", i, i / 32);
  printf("%d 를 5 칸 쉬프트 하면 : %d \n", i, i >> 5);

  return 0;
}

실행 결과

정수를 입력하세요 : 120
120 를 32 로 나누면 : 3
120 를 5 칸 쉬프트 하면 : 3

두 결과가 일치함을 볼 수 있다.

비트 연산 활용하기 - 1

비트 연산 (OR, AND, XOR 등등) 은 컴퓨터에서 가장 빠르게 실행되는 연산들 이다. 이러한 연산들을 잘 활용하면 좋을 것이다. 일단 비트 연산은 다음과 같이 여러가지 정보를 하나의 변수에 포함하는데 자주 사용된다. 예를 들어서 우리가 하나의 사람에 대한 여러가지 정보를 나타내는 변수를 만든다고 해보자. 구조체를 배운 우리는 아래와 같이 만들 것이다.

struct HUMAN {
  int is_Alive;
  int is_Walking;
  int is_Running;
  int is_Jumping;
  int is_Sleeping;
  int is_Eating;
};

이는 상당한 메모리 낭비가 될 것이다. 6 가지 정보를 나타내는데 192 개의 비트나 소모했기 때문이다. 물론 이를 char 로 바꾸면 되지 않나 라고 물어볼 수 있겠지만 결국은 같은 얘기이다.

굳이 하나의 정보를 한 개의 비트에 대응시켜서 사용할 수 있는데 이를 각각의 변수에 대응시켜서 사용한 것이 문제이다. 하지만 비트 연산을 잘 이용하면 이를 해결할 수 있다. 아래 예제를 보자

#include <stdio.h>
#define ALIVE 0x1      // 2 진수로 1
#define WALKING 0x2    // 2 진수로 10
#define RUNNING 0x4    // 2 진수로 100
#define JUMPING 0x8    // 2 진수로 1000
#define SLEEPING 0x10  // 2 진수로 10000
#define EATING 0x20    // 2 진수로 100000
int main() {
  int my_status = ALIVE | WALKING | EATING;

  if (my_status & ALIVE) {
    printf("I am ALIVE!! \n");
  }
  if (my_status & WALKING) {
    printf("I am WALKING!! \n");
  }
  if (my_status & RUNNING) {
    printf("I am RUNNING!! \n");
  }
  if (my_status & JUMPING) {
    printf("I am JUMPING!! \n");
  }
  if (my_status & SLEEPING) {
    printf("I am SLEEPING!! \n");
  }
  if (my_status & EATING) {
    printf("I am EATING!! \n");
  }
  return 0;
}

실행 결과

I am ALIVE!!
I am WALKING!!
I am EATING!!

와 같이 하나의 int 변수에 위 모든 데이터를 나타낼 수 있다. 이유는 아래와 같다.

#define ALIVE 0x1     // 2 진수로 1
#define WALKING 0x2   // 2 진수로 10
#define RUNNING 0x4   // 2 진수로 100
#define JUMPING 0x8   // 2 진수로 1000
#define SLEEPING 0xC  // 2 진수로 10000
#define EATING 0x10   // 2 진수로 100000

define 을 이용하여 여러개의 변수에 값을 대응 시켰는데 한 가지 특징은 각 데이터에는 오직 한 개의 비트만 1이고 나머지는 0 인 것이다. 예를 들어서 JUMPING 을 보면 16진수 8에 대응시켰는데 이는 2진수로 보면 4 번째 자리면 1 이고 나머지 모든 자리는 0 인 수가 된다. 따라서 이와 같은 방식으로 수를 대응 시키고

int my_status = ALIVE | WALKING | EATING;

와 같이 my_statusOR 연산을 시켜주게 되면 각 데이들을 나타내는 자리만 1 이 되고 나머지는 0 이 될 것이다. 따라서 my_status0....010011 이 될 것이다. 이제 이를 이용하여 if 문에서 쉽게 사용할 수 있는데 단순히 유무만 파악하고 하는 데이터와 AND 연산을 시키면 된다.

if (my_status & WALKING) {
  printf("I am WALKING!! \n");
}

예를 들면 위의 경우 내가 현재 WALING 중인지를 확인하기 위해 WALINGAND 연산을 통해 내가 WALING 중이였다면 AND 연산시 '나머지 부분은 모두 0 이 될 것이고 WALING 에 해당하는 자리만 1 이 될 것'이여서 if 문에서 참으로 판단되고 (if 에서는 0이 아닌 모든 것이 참이다) 내가 WALING 중이 아니였다면 모든 자리수의 값이 0이 되어 if 문에서 거짓으로 판단된다.

참고로 비트 연산은 아래의 내용을 기억하면 편리하다.

  1. 어떠한 정수의 특정 자리를 1 로 만들고 싶다면 그 자리만 1 이고 나머진 0 인 인수와 OR 하면 된다.
  2. 어떠한 정수의 특정 자리가 1인지 검사하고 싶으면 그 자리만 1 이고 나머진 0 인 인수와 AND 하면 된다.

비트 연산 활용하기 - 2

비트 연산을 가장 많이 활용하는 예로는 홀수/짝수 판별이 있다. 여태까지 홀수 짝수 판별을 아래와 같이 했을 것이다.

if (i % 2 == 1)  // 이 수가 홀수인가
{
  printf("%d 는 홀수 입니다 \n", i);
} else {
  printf("%d 는 짝수 입니다 \n", i);
}

그런데 앞서 계속 말했듯이 나눗셈 연산은 매우매우 느리다. 하지만 이를 AND 연산을 활용하면 매우 간단하게 만들 수 있다.

if (i & 1)  // 이 수가 홀수인가
{
  printf("%d 는 홀수 입니다 \n", i);
} else {
  printf("%d 는 짝수 입니다 \n", i);
}

만일 어떠한 정수가 홀수라면, 2진수로 나타내면 1의 자리의 값은 1 이 되어야 한다.

이를 이용해보면 단순히 어떠한 정수의 맨 마지막 비트가 1 인지만 확인하면 되는 것이다. 그렇다면 어떻게 마지막 비트가 1 인지 확인할 수 있을까? 앞서 배웠듯이 AND 연산을 이용해 보면 맨 마지막 비트만 1인 수(즉 1) 과 AND 하면 된다. 아래 소스로 컴파일 해서 실행해보면 잘 되는 것을 볼 수 있다.

#include <stdio.h>
int main() {
  int i;
  scanf("%d", &i);

  if (i & 1)  // 이 수가 홀수인가
  {
    printf("%d 는 홀수 입니다 \n", i);
  } else {
    printf("%d 는 짝수 입니다 \n", i);
  }
  return 0;
}

실행 결과

33 는 홀수 입니다

루프(loop) 관련

알고 있는 일반적인 계산 결과를 이용해라

대표적으로 이야기 하자면 1 부터 n 까지 더하는 함수를 만들 때이다. 일반적으로 이러한 작업을 하는 코드를 짤 때에는

for (i = 1; i <= n; i++) {
  sum += i;
}

위와 같이 for 문으로 구현하는 경우가 대부분이다. 하지만 등차수열을 배웠더라면 위와 같이 일일히 더하는 것 말고도

sum = (n + 1) * n / 2;

로 간단히 나타낼 수 있다. 이러한 경우 시간이 매우 절약된다.

끝낼 수 있을 때 끝내라

아래 코드는 특정한 문자열에 a 라는 문자가 포함되어 있는지를 검사하는 코드이다.

while (*pstr) {
  if (*pstr != 'a') {
    does_string_has_a = 1;
  }

  pstr++;
}

위 코드에서 does_string_has_a 가 한 번 1이 되었다면 그 뒤에서 바꿀 일이 없기 때문에 굳이 반복문을 끝까지 실행하는 것은 무의미하다. 이 때, break 를 이용하여 빠져나갈 수 있다면 불필요한 실행을 줄일 수 있다.

while (*pstr) {
  if (*pstr != 'a') {
    does_string_has_a = 1;
    break;
  }

  pstr++;
}

이와 같이 말이다.

한 번 돌 때 많이 해라

하나의 루프에서 동일한 일을 2 번 하는 것과, 하나의 루프에서 동일한 일을 한 번 하고 루프를 두번 돈다면 둘 중에 전자의 경우가 더 훨씬 효율적이라고 할 수 있다. 왜냐하면 루프가 한 번 돌 때 여러가지 조건에 맞는지 비교하는 부분에서 대부분의 시간이 약간 소모되기 때문에 따라서 되도록이면 루프 한 번에 안에서 많은 일을 해버리는 것이 중요하다.

아래 코드는 정수 n 에서 값이 1 인 비트가 몇 개 존재하는지 세는 프로그램이다.

while (n != 0) {
  if (n & 1) {
    one_bit++;
  }
  n >>= 1;
}

위 코드는 맨 끝 한개의 비트가 1인지 검사하고 오른쪽으로 한 칸 쉬프트 해서 다시 맨 끝 비트를 검사하는 식으로 해서 결과적으로 모든 비트를 검사하여 값이 1 인 것의 개수를 센다. 하지만 우리는 C 언어에서 모든 정수 자료형의 크기가 8 비트의 배수임을 알고 있다.

예를 들어 char 는 1 바이트로 8 비트, int 는 4 바이트로 32 비트이다. 따라서 굳이 1개의 비트씩 검사할 필요 없이 8 비트를 한꺼번에 묶어서 검사해도 상관이 없다는 말이다. 이 때 8 비트를 한꺼번에 비교하면 너무 난잡하므로 4 비트 씩 비교하는 것이다.

while (n != 0) {
  if (n & 1) {
    one_bit++;
  }
  if (n & 2) {
    one_bit++;
  }
  if (n & 4) {
    one_bit++;
  }
  if (n & 8) {
    one_bit++;
  }
  n >>= 4;
}

와 말이다. 사실 for 이나 if 는 한 줄만 올 경우 중괄호를 생략해도 된다. 따라서 아래와 같이 하였다.

while (n != 0) {
  if (n & 1) one_bit++;
  if (n & 2) one_bit++;
  if (n & 4) one_bit++;
  if (n & 8) one_bit++;
  n >>= 4;
}

위와 같이 하면 루프를 도는 횟수를 줄일 수 있게 되므로 어느정도 시간을 줄일 수 있다.

루프에서는 되도록 0 과 비교해라

for (i = 0; i < 10; i++) {
  printf("a");
}

for (i = 9; i != 0; i--) {
  printf("a");
}

위 두 반복문에서 어떠한 것이 더 빠르게 실행될까? 실제로 그리 큰 차이는 없겠지만 엄밀히 말하자면 아래의 반복문이 더 빠르게 실행된다. 위 반복문은 i 가 10 보다 작은지 비교하고 아래의 반복문은 i 가 0 과 다른지 비교하는데 일반적으로 0 과 비교하는 명령어는 CPU 에서 따로 만들어져 있기 때문에 더 빠르게 작동될 수 있다.

되도록 루프를 적게 써라

루프문을 굳이 사용하지 않아도 되는 문장은 직접 쓰는게 좋다. 예를 들어

int i;
for (i = 1; i <= 3; i++) {
  func(i);
}

보다는

func(1);
func(2);
func(3);

와 같이 루프를 풀어버리는 것이 더 좋을 때가 있다. 물론 루프를 사용하면 무엇을 하는지 한눈에 알 수 있지만 반복문에서 조건을 비교하는데 시간이 더 들기 때문에 위와 같이 간단히 루프를 사용하지 않고 나타낼 수 있다면 그 방법을 선택하는 것을 추천한다.

if 및 switch 문 관련

if 문을 2 의 배수로 쪼개기

예를 들어서 아래와 같은 예제가 있다고 해보자

if (i == 1) {
} else if (i == 2) {
} else if (i == 3) {
} else if (i == 4) {
} else if (i == 5) {
} else if (i == 6) {
} else if (i == 7) {
} else if (i == 8) {
}

(물론 위의 경우 switch 를 사용하는게 훨씬 바람직하다) 위의 예제에 경우 최악의 경우에는 최대 8번 까지 비교작업을 해야하는 경우가 발생한다. 이는 엄청난 낭비이다.

if (i <= 4) {
  if (i <= 2) {
    if (i == 1) {
      /* i is 1 */
    } else {
      /* i must be 2 */
    }
  } else {
    if (i == 3) {
      /* i is 3 */
    } else {
      /* i must be 4 */
    }
  }
} else {
  if (i <= 6) {
    if (i == 5) {
      /* i is 5 */
    } else {
      /* i must be 6 */
    }
  } else {
    if (i == 7) {
      /* i is 7 */
    } else {
      /* i must be 8 */
    }
  }
}

하지만 위의 경우와 같이 구성하면 어떨까? 이와 같이 if 문을 쪼개는 작업을 Binary Breakdown 이라고 하는데 이진의 형태로 쪼개는 것이다. 이럴 경우 i 가 1 에서 8 사이에 어떠한 수를 가지더라도 3 번만 비교하면 어떤 값을 가지는지 알아낼 수 있다. 참고로 이전에 if 문의 형태로는 평균적으로 4 번 비교가 필요했을 것이다.

순차적 비교에서는 switch 문을 사용해라

대부분의 현대 컴파일러들은 이 작업을 알아서 최적화 해준다.

사실 위의 if 문 예제에서는, 즉 위와 같이 순차적인 값을 비교하는 경우에는 switch 를 사용하는 것이 더 요긴하다. 왜냐하면 switch 문에서는 단 한번의 비교만으로 우리가 실행될 코드가 있는 곳으로 점프하기 때문이다. 이에 대한 설명은 switch 문 강의에서 자세히 설명하였다.

따라서 아래의 코드가 더 효율적이다.

switch (i) {
  case 1:
    break;
  case 2:
    break;
  case 3:
    break;
  case 4:
    break;
  case 5:
    break;
  case 6:
    break;
  case 7:
    break;
  case 8:
    break;
}

룩업 테이블(look up table, LUT)을 사용할 수 있으면 사용해라

룩업 테이블이란, 원론적으로 설명하면 특정 데이터에서 다른 데이터로 변환할 때 자주 사용되는 테이블이라고 할 수 있다. 말만 들으면 조금 어려운데, 사실 컴퓨터에서 자주 사용되고 있다.

예를 들어서 컴퓨터에서 3D 처리를 할 때 많은 수의 sin (사인)이나 cos (코사인) 연산들이 들어가게 된다. 이 때, sin 값 계산은 상당히 오래 걸리는 작업이다. sin 1 이라는 값이 필요할 때 마다 계산하게 된다면 아주 시간 낭비가 심할 것이다. 이를 막기 위해서 프로그램 초기에 sin 1 부터 sin 90 까지 미리 계산한 다음에 표로 만들어 두면 나중에 sin 1 의 값이 필요하다면 단순히 표에서 1 번째 값을 찾아와서 가져오면 되니까 아주 편할 것이다.

이렇게 만들어 놓은 테이블을 '룩업 테이블' 이라 부른다. 즉, 필요한 데이터를 쉽게 찾을 수 있도록 미리 표로 만들어둔 것 이라고 보면 된다. 예를 들면 아래와 같은 경우 사용할 수 있다.

char* Condition_String1(int condition) {
  switch (condition) {
    case 0:
      return "EQ";
    case 1:
      return "NE";
    case 2:
      return "CS";
    case 3:
      return "CC";
    case 4:
      return "MI";
    case 5:
      return "PL";
    case 6:
      return "VS";
    case 7:
      return "VC";
    case 8:
      return "HI";
    case 9:
      return "LS";
    case 10:
      return "GE";
    case 11:
      return "LT";
    case 12:
      return "GT";
    case 13:
      return "LE";
    case 14:
      return "";
    default:
      return 0;
  }
}

위 코드의 경우 꽤 괜찮지만 아래 처럼 훨씬 간단하게 만들 수 있다.

char* Condition_String2(int condition) {
  if ((unsigned)condition >= 15) {
    return 0;
  }
  char* table[] = {"EQ", "NE", "CS", "CC", "MI", "PL", "VS",
                   "VC", "HI", "LS", "GE", "LT", "GT", "LE"};
  return table[condition];
}

이렇게 룩업 테이블을 이용하면 좋은 점은 코드가 짧아지고 실제 프로그램 크기도 줄어든다는 것이다.

함수 관련

함수를 호출할 때는 시간이 걸린다.

다음 나오는 두 예제 중에서 어떠한 예제가 더 빠르게 작동할까?


#include <stdio.h> void print_a();

int main() {
  int i;
  for (i = 0; i < 10; i++) {
    print_a();
  }
  return 0;
}
void print_a() { printf("a"); }
#include <stdio.h>
void print_a();
int main() {
  print_a();
  return 0;
}
void print_a() {
  int i;
  for (i = 0; i < 10; i++) {
    printf("a");
  }
}

정답은 후자이다. 왜냐하면 함수를 호출할 때에도 시간이 꽤 걸리기 때문이다. 함수를 호출하기 위해선 여러가지 작업이 필요한데 이 부분에 대한 설명은 생략하고 아무튼 위와 같은 동일한 작업을 함수를 반복해서 호출하는 것 보다는 차라리 함수 내에서 반복적인 작업을 처리하는 것이 훨씬 더 효율적이다.

인라인(inline) 함수를 활용하자

#include <stdio.h>
int max(int a, int b) {
  if (a > b) return a;
  return b;
}
__inline int imax(int a, int b) {
  if (a > b) return a;

  return b;
}
int main() {
  printf("4 와 5 중 큰 것은?", max(4, 5));
  printf("4 와 5 중 큰 것은?", imax(4, 5));
  return 0;
}

위의 두 개의 printf 문에서 어떠한 작업이 더 빠르게 실행될까? 바로 inline 함수를 이용하는 것이다. 위와 같이 max 와 같은 단순한 작업을 하는 함수를 만들 때에는 인라인 함수를 사용하는 것이 훨씬 더 효율적이다.

이미 잘 알고 있겠지만 인라인 함수는 함수가 아니다. (자세한 설명은 메크로 함수 강의를 참조). 반면에 max 함수는 실제로 함수를 호출하는 과정부터 여러가지 작업이 필요한데, 정작 함수 내부에서 하는 작업은 매우 단순하여 오히려 함수 내부에서 하는 작업 시간보다 함수를 호출하는 시간이 더 오래 걸리는데 배보다 배꼽이 더 큰 격이 된다. 따라서 위와 같이 단순한 작업을 하는 함수를 만들 때에는 인라인 함수를 사용하는 것이 더 효율적이다.

인자를 전달할 때에는 포인터를 사용해라

struct big {
  int arr[1000];
  char str[1000];
};

위와 같이 매우 큰 구조체가 있다고 해보자. 그런데 만약에 arr[3] 값을 얻어 오는 함수를 만들고 싶다면 어떻게 해야할까? 물론 아래와 같이 코드를 짜는 사람도 있을 것이다.

void modify(struct big arg) {/* 무언가를 한다 */}

하지만 이 함수를 호출한다면 modify 함수의 arg 인자로 구조체 변수의 모든 데이터가 복사되어야 하는데 이는 엄청난 시간이 소모된다. 말 그대로 5000 바이트나 되는 데이터의 복사를 수행할 뿐더러 modify 변수의 메모리 공간을 위한 할당도 따로 필요하기 때문이다. 그렇다면 아래의 코드는 어떨까?

void modify(struct big *arg) { /* 무언가를 한다 */ }

위는 단순히 구조체 변수의 주소값을 얻어 온다. 이는 단순히 8 바이트의 주소값 복사만이 일어날 뿐 앞선 예제와 같이 무지막지한 복사는 일어나지 않는다. 뿐만 아니라 동일하게 전달된 구조체 변수의 데이터들도 쉽게 읽어올 수 있다. 단순히 arg -> arr[3] 과 같은 방식으로 말이다. 여러분은 언제나 이 점을 명심하고 인자로 전달할 때에는 포인터를 자주 활용하자

profile
벨로그보단 티스토리를 사용합니다! https://flight-developer-stroy.tistory.com/

0개의 댓글