실수 없는 프로그래밍을 위한 몇 가지 방법

프로그래밍 대회에서 좋은 성적을 올리기 위한 방법에는 어떤 것이 있을까요? 아마 대부분의 사람들이 다양한 알고리즘을 알고 있는 것이라 대답할 것입니다. 물론 문제를 풀기 위해서 여러 알고리즘을 아는 것은 매우 중요합니다. 하지만 단순히 알고리즘만 많이 알고 있다고 해서 프로그래밍 대회에서 좋은 성적을 거둘 수 있는 것은 아닙니다. 그렇다면 어떤 것이 가장 중요할까요?


혼자서 공부를 할 때는 제한시간이 없기 때문에 여유로운 마음으로 코딩을 할 수 있습니다. 하지만 프로그래밍 대회에서는 그렇지 않습니다. 제한시간이 있고 다른 사람들이 문제를 얼마나 풀었는지 볼 수 있습니다. 또한 팀원들이 같이 있을 경우 내가 잡은 문제를 꼭 풀어야 한다는 압박감을 받을 수 있습니다. 이러한 상황 속에서 여유로운 마음으로 코딩을 하기 쉽지 않습니다. 이때 평상시와 같이 코딩을 할 수 있게 도와주는 것은 철저하게 훈련된 코딩 능력입니다.


여기서 말하는 코딩 능력은 구현 능력과는 다릅니다. 구현 능력은 내가 생각한 것을 코드로 옮기는 능력이고 코딩 능력은 정확하고 읽기 쉬운 코드를 작성하는 능력입니다. 이 글에서는 코딩 능력을 높이기 위한 몇 가지 주제를 다뤄보려고 합니다.



책갈피



1. 들여쓰기의 중요성

2. 자신만의 코딩 컨벤션

3. 매크로 사용을 지양

4. 코드의 재활용

5. 의미있는 작명

6. STL과 c++11을 활용



들여쓰기의 중요성



코딩을 할 때 들여쓰기는 생명과도 같습니다. 혹자는 당연히 지켜야 하는 것 아니냐 얘기할 수 있지만 생각보다 많은 사람들이 들여쓰기를 지키지 않고 코딩을 합니다. 아래 코드는 들여쓰기를 지키지 않은 코드입니다.


#include <stdio.h>
#include <algorithm>

int main() {
  int n, A[1010];
  scanf("%d", &n);
  
  for (int i = 0 ; i < n ; ++i) {
  scanf("%d", &A[i]);
  }
  
  for (int i = 0 ; i < n ; ++i) {
  for (int j = 0 ; j < i ; ++j) {
    if (A[j] > A[i]) {
    std::swap(A[i], A[j]);
  }
  }
  }
}


단순한 코드임에도 불구하고 한눈에 알아보기 힘듭니다. 20줄 남짓한 코드조차 알아보기 힘든데 코드가 길어진다면 어떻게 될까요? 그 누구도 읽기 싫은 코드가 될 것이 자명해 보입니다.


물론 미관상의 이유만으로 들여쓰기를 지키라는 것이 아닙니다. Python, Ruby 같은 언어와 달리 C, C++, Java는 중괄호로 Block Scope가 나눠집니다. 때문에 Block 안에서만 유효한 변수 또는 로직이 있을 수 있습니다. 만약 들여쓰기를 지키지 않는다면 한 Block의 시작과 끝을 한눈에 알 수 없어 자신이 선언한 변수나 로직이 어디서부터 어디까지 영향을 미치는지 다시 확인을 해야 하는 경우가 발생할 수 있습니다. 


1분 1초도 아까운 프로그래밍 대회에서 들여쓰기를 지키는 것만으로 디버깅 시간을 줄일 수 있다면 당연히 들여쓰기를 지켜야 할 것입니다. 거의 모든 에디터들이 자동 들여쓰기를 지원해 주니 이를 활용하면 됩니다.


위에 들여쓰기가 엉망인 코드는 아래와 같이 바꿔야 합니다.


#include <stdio.h>
#include <algorithm>

int main() {
  int n, A[1010];
  scanf("%d", &n);
  
  for (int i = 0 ; i < n ; ++i) {
    scanf("%d", &A[i]);
  }
  
  for (int i = 0 ; i < n ; ++i) {
    for (int j = 0 ; j < i ; ++j) {
      if (A[j] > A[i]) {
        std::swap(A[i], A[j]);
      }
    }
  }
}



 자신만의 코딩 컨벤션



프로그래밍 대회를 준비하면 수많은 종류의 알고리즘을 공부하고 반복적으로 작성하게 됩니다. 한 알고리즘을 구현할 때 여러 가지 방법을 시도해 봅니다. \(while\)과 \(do-while\)을 돌아가며 써보기도 하고, 반복문과 재귀를 번갈아 가며 써보기도 합니다. 이는 처음 알고리즘을 배우고 자신에게 최적화할 때 도움이 됩니다. 하지만 이런 방법을 계속해서 사용을 한다면 잦은 실수의 원인이 되기도 합니다.


새로운 방법으로 작성한 코드가 제대로 동작하는지 확인을 하기란 쉬운 일이 아닙니다. 물론 이렇게 바꾼 코드가 기대하던 대로 동작하고 성능이 향상될 수도 있지만 여러 변수가 존재하는 프로그래밍 대회에서 이런 여유를 부리다가는 좋지 않은 결과를 받기 십상입니다.


어떻게 하면 효율적이고 읽기 쉬운 코드를 짤 수 있는지 계속해서 생각을 할 필요는 있습니다. 하지만 이것을 굳이 시간에 쫓기는 프로그래밍 대회 때 할 필요는 없습니다. 자주 사용하는 알고리즘들은 여러 번 반복해서 작성해보고 자신만의 코딩 컨벤션을 확립할 필요가 있습니다. 그래야 문제를 풀 때 어떻게 구현을 할지 생각을 하지 않고 오로지 어떻게 풀지에 집중을 할 수 있습니다.



무분별한 매크로 사용을 지양



C에서는 어떤 문자열을 원하는 문자열로 치환시켜주는 매크로가 존재합니다. 많은 사람들이 \(min, max, square\) 등의 함수들을 매크로 함수로 정의해 사용하곤 합니다. 하지만 매크로 함수의 정확한 이해 없이 사용하다간 끝나지 않는 디버깅의 늪에 빠질 수 있습니다.


아래는 매크로 함수에 대한 예시입니다.


#include <stdio.h>
#define max(a, b) ((a) > (b) ? (a) : (b))

int main() {
  int a = 10;
  int b = 20;
  
  int mmax1 = max(a, b);
  int mmax2 = max(a, b++);
  
  printf("%d %d\n", mmax1, mmax2);
}


어떤 의도를 가지고 작성된 코드인지는 쉽게 알 수 있습니다. 그렇다면 어떤 값이 출력될까요? [20 20]이 출력된다고 생각하시는 분이 많을 거라 생각됩니다. 하지만 코드를 직접 실행해 본다면 [20 21]이 출력되는 것을 볼 수 있습니다. 만약 이러한 일이 프로그래밍 대회 때 발생한다면 어떻게 될까요. 매크로 함수를 잘못 사용했다는 생각을 못하고 알고리즘을 계속해서 확인할 확률이 높습니다. 생각만 해도 정말 끔찍한 일입니다.


그럼 왜 이러한 일이 일어나는지 간단하게 알아봅시다. C에서 제공하는 매크로는 컴파일 시간 전에 이뤄지는 텍스트의 치환 작업입니다. 즉 위 코드는 다음 코드와 같이 컴파일이 이뤄집니다.


#include <stdio.h>

int main() {
  int a = 10;
  int b = 20;
  
  int mmax1 = ((a) > (b) ? (a) : (b));
  int mmax2 = ((a) > (b++) ? (a) : (b++));
  
  printf("%d %d\n", mmax1, mmax2);
}


삼항 연산자 안에 후치 연산이 두 번이나 들어가 있는 형태가 돼버립니다. 심지어 \(b\)를 21로 만들려고 했음에도 최종적으로 22라는 값을 가집니다. 이렇듯 조금 복잡한 로직을 매크로 함수에 녹이게 된다면 직접 써보기 전까지 어떠한 일이 일어나는지 추적을 하기가 쉽지 않습니다.


매크로 함수를 정확하게 이해하고 사용한다면 이런 일이 발생하지 않겠지만 이보다 더 최선은 매크로 함수를 아예 사용하지 않는 것입니다. C++에서는 \(inline\) 함수가 있으므로 매크로 함수 대신 이를 사용하면 위와 같은 일을 원천봉쇄할 수 있습니다. 아래는 \(inline\) 함수를 사용한 예시입니다.


#include <stdio.h>

inline int max(int a, int b) {
  return a > b ? a : b;
}

int main() {
  int a = 10;
  int b = 20;
  
  int mmax1 = max(a, b);
  int mmax2 = max(a, b++);
  
  printf("%d %d\n", mmax1, mmax2);
}



코드의 재활용



코딩을 하다 보면 반복해서 나오는 로직이 있습니다. 이때 반복되는 로직을 어떻게 처리하냐에 따라 코딩의 정확성, 속도가 달라집니다. 반복되는 로직이 나오면 이를 모듈화하는 것이 일반적입니다. 프로그래밍 대회에서 코딩을 한다고 이는 달라지지 않습니다. 하지만 시간에 쫓기는 프로그래밍 대회에서 반복되는 로직을 모듈화하는데 시간을 할애하기 쉽지 않습니다. 그럼에도 불구하고 코드를 재활용해야 하는 이유는 무엇일까요?


첫째, 코드를 한층 더 읽기 쉽게 만들어 줍니다. 가독성이 높은 코드가 눈에 더 잘 들어오고 다른 사람들을 이해시키기 훨씬 쉽습니다. 또한 계속해서 간결한 코드를 보게 된다면 간결한 코드를 짜는데 익숙해집니다.


둘째, 디버깅 시간이 확연히 줄어듭니다. 만약 한 로직이 전체 코드에서 다섯 번 사용됐다고 가정을 합시다. 그런데 그 로직이 잘못됐다면 다섯 군데를 수정해야 합니다. 하지만 이를 모듈화했다면 단 한 군데만 수정하는 것으로 디버깅을 끝낼 수 있습니다. 또한 오타가 나기 쉬운 로직 같은 경우 한, 두 군데만 잘못될 수 있습니다. 이런 경우 모듈화를 하지 않았다면 디버깅 시간이 많게는 열 배 이상 차이 날 수 있습니다.


아래는 어떠한 동작을 하는 코드입니다. 어떤 일을 하는지 읽어보며 생각해 봅시다.


#include <stdio.h>

int t, n, m, x[110], y[110];

int main() {
  scanf("%d%d", &n, &m);
  for (int i = 1 ; i < n ; ++i) {
    scanf("%d%d", &x[i], &y[i]);
  }
  for (int i = n ; i < n + m ; ++i) {
    scanf("%d%d", &x[i], &y[i]);
  }
  
  scanf("%d", &t);
  while (t--) {
    int q, from , to, ans;
    
    scanf("%d%d%d", &q, &from, &to);
    if (q == 1) {
      ans = (x[from] - x[to]) * (x[from] - x[to]) + (y[from] - y[to]) * (y[from] - y[to]);
    } else if (q == 2) {
      ans = (x[from + n] - x[to + n]) * (x[from + n] - x[to + n]) + (y[from + n] - y[to + n]) * (y[from + n] - y[to + n]);
    } else if (q == 3) {
      ans = (x[from] - x[to + n]) * (x[from] - x[to + n]) + (y[from] - y[to + n]) * (y[from] - y[to + n]);
    }
    
    printf("%d\n", ans);
  }
}


먼저 \(A\) 그룹, \(B\) 그룹의 좌표를 입력받고 쿼리에 따라 \(A\) 그룹 간의 거리, \(B\) 그룹 간의 거리, \(A\) 그룹과 \(B\) 그룹간의 거리를 구해서 출력하는 코드입니다. 거리를 구하는 로직이 세 번이나 반복되는 것을 볼 수 있습니다. 또한 쿼리를 처리하는 부분이 어떤 일을 하는지 설명을 듣기전에는 이해하기 힘듭니다. 만약 이 부분을 모듈화 하면 어떻게 될까요?


#include <stdio.h>

int t, n, m, x[110], y[110];

int dist(int from, int to) {
  return (x[from] - x[to]) * (x[from] - x[to]) + (y[from] - y[to]) * (y[from] - y[to]);
}

int main() {
  scanf("%d%d", &n, &m);
  for (int i = 1 ; i < n ; ++i) {
    scanf("%d%d", &x[i], &y[i]);
  }
  for (int i = n ; i < n + m ; ++i) {
    scanf("%d%d", &x[i], &y[i]);
  }
  
  scanf("%d", &t);
  while (t--) {
    int q, from , to, ans;
    
    scanf("%d%d%d", &q, &from, &to);
    if (q == 1) {
      ans = dist(from, to);
    } else if (q == 2) {
      ans = dist(from + n, to + n);
    } else if (q == 3) {
      ans = dist(from, to + n);
    }
    
    printf("%d\n", ans);
  }
}


쿼리를 처리하는 부분이 훨씬 간결해진 것을 볼 수 있습니다. 또한 모듈화 한 부분이 어떤 일을 하는지 한눈에 볼 수 있게 됐습니다. 이렇듯 코드를 재활용하는 것은 잃는 것보다 얻는 것이 훨씬 더 많으므로 이에 익숙해질 필요가 있습니다.



의미있는 작명



우스갯소리로 프로그래머들이 코딩을 할 때 변수 및 함수 이름을 짓는데 가장 많은 시간이 소요된다고 합니다. 하지만 이를 단순 우스갯소리로 치부하면 안 됩니다. 그만큼 변수 및 함수의 작명이 중요하기 때문입니다. 하지만 프로그래밍 대회에서 사용되는 코드들을 보면 작명에 신경 쓰지 않은 경우가 많습니다. 프로그래밍 대회를 준비하는 사람이라면 아래와 같은 작명을 많이 봤을 겁니다.


#include <stdio.h>

int A[110], B[110], C[110], a, aa, b, bb;

bool flag(...) {
  // ...
}

int main() {
  /*
   main function
   */
}


이런 작명은 코딩을 한 본인은 알아볼 수 있을지 모르겠지만 다른 사람 혹은 같은 팀원이 보기에는 최악의 코드입니다. 또한 디버깅을 할 때 변수 혹은 함수가 어떤 역할을 하는지 계속해서 생각을 해야 하기 때문에 시간이 더 오래 걸립니다. 물론 작명을 하는데 엄청난 시간을 할애하라는 것이 아닙니다. 작성자 본인 혹은 같은 팀원이 봤을 때 어떤 역할을 하는지 대략적으로 알 수 있다면 그것으로 충분합니다.


프로그래밍 대회에서 많이 사용하는 알고리즘들은 함수 및 변수들을 의미 있게 작명한 다음 꾸준히 사용하는 것도 좋은 방법입니다.



STL과 C++11의 활용



알고리즘 공부를 처음 시작하는 사람들이 흔히 하는 생각은 "처음부터 끝까지 가내수공업" 입니다. 벡터, 큐, 균형잡힌 이진트리 등 많은 자료구조들을 직접 구현하고 사용합니다. 물론 한 번쯤은 직접 구현해보는 것은 좋은 방법입니다. 하지만 시간제한이 있는 대회에서 이들을 직접 구현해서 쓴다는 것은 시간 낭비입니다.

(간혹가다 수행 시간에 병적으로 집착한다거나 컴파일 최적화를 안 해주는 대회에서는 어쩔 수 없이 직접 구현해야 할 때도 있긴 하지만 대부분의 경우는 아닙니다.)


C++에서는 표준 템플릿 라이브러리(이하 STL)를 제공합니다. 벡터, 큐, 맵, 셋 등 여러 자료구조들을 헤더 파일을 하나 추가하는 것만으로 사용할 수 있습니다. 이 STL들은 수많은 사람들이 사용하고 수없이 많이 검증됐기 때문에 믿고 사용할 수 있습니다. 또한 누가 사용하던 인터페이스가 똑같기 때문에 다른 팀원이 봤을 때 코드를 이해하기 한결 수월합니다. 그렇기 때문에 STL 사용방법을 익히는 것은 필수적입니다.


다음은 C++11에 대한 내용입니다.(C++11에 대해서 자세한 내용은 이 글에서는 다루지 않습니다.) C++11을 활용하면 익명 함수를 사용해 코드의 가독성을 높일 수 있을 뿐더러  \(for-each\)구문을 사용해 배열의 범위 밖에 접근하는 것을 방지할 수 있습니다. 따라서 C++11의 문법을 익히고 사용한다면 코딩을 할 때 실수할 확률을 줄일 수 있습니다.

Designed by JB FACTORY