좋은 코드를 짜기 위한 노력

Posted on 2022. 01. 20


⚙좋은 코드를 짜기 위한 원칙

본격적으로 문제풀이에 들어가기 전에, <종만북>에서 강조한 좋은 코드를 짜기 위한 원칙을 되새겨 보았다.
(최근에 급하게 개발을 하다보니까 클린코드에 대한 관심이 커졌고, 필요성을 절실히 느끼는 중이다.)


💎HOW TO WRITE CLEAN CODE

1. 간결한 코드 작성하기

코드가 짧으면 짧을수록 오타나 단순한 버그가 생길 우려가 줄어들고, 디버깅도 쉬워진다. 같은 일을 하는 100줄짜리 코드 대신 1000줄 짜리 코드를 보고 싶어하는 사람은 아무도 없듯이, 이 원칙은 어느 프로그램에나 적용된다.

  • 전역 변수의 광범위한 사용) 전역 변수를 많이 사용하면 프로그램의 흐름을 파악하기가 어려워지기 때문에 대개 사용하지 않는 것이 좋다.

  • 매크로) 가끔 사용하면 아주 유용하나 신중하게 사용해야 한다.
    예를 들어 정렬되지 않은 정수 배열에 중복 원소가 존재하는지 확인하는 함수가 있다고 하자.

bool hasDuplicate(const vector<int>& array) { for(int i=0; i < array.size(); ++i) { for(int j=0; j < i; ++j) { if(array[i] == array[j]) return true; } } return false; }

이 코드는 매크로를 사용하면 다음과 같이 바꿀 수 있다.

#define FOR(i, n) for(int i=0; i < (n); ++i) bool hasDuplicate(const vector<int>& array) { FOR(i, array.size()){ FOR(j, i) { if(array[i] == array[j]) return true; } } return false; }

2. 적극적으로 코드 재사용하기

간결한 코드를 작성하기 위한 가장 직접적인 방법은 코드를 모듈화하는 것이다.
같은 코드가 반복된다면 이들을 함수나 클래스로 분리해 재사용하는 것이 좋다. (같은 코드가 세 번 이상 등장한다면 항상 해당 코드를 함수로 분리해 재사용한다는 기본 원칙을 만들면 좋다고 한다.)


3. 표준 라이브러리 공부하기

큐나 스택과 같은 자료구조, 혹은 정렬 등의 기초적 알고리즘을 직접 작성하는 것은 프로그래밍 대회에서는 시간 낭비이다. 표준 라이브러리는 셀 수 없을 정도로 많이 사용되고 검증되었기 때문에, 메모리 관리나 정당성 증명에 신경 쓸 필요 없이 편하게 사용할 수 있다.
따라서, 언어의 문자열, 동적 배열, 스택, 큐, 리스트, 사전(키가 주어졌을 때 해당 값을 반환하는 자료 구조, Associative Array) 등의 자료구조, 그리고 정렬 등의 표준적인 알고리즘 구현 사용법을 반드시 잘 알아두자.


4. 일관적이고 명료한 명명법 사용하기

예를 들어, 2차원 평면 상에 한 개의 점과 원이 주어졌을 때 점이 원 안에 포함되는지 여부를 반환하는 함수를 다음과 같이 작성했다고 하자.

bool judge(int y, int x, int cy, int cx, int cr);

이 코드에서 알 수 있는 것은 두 개의 2차원 좌표와 또 다른 값이 입력으로 주어지고, 좌표 순서는 yy , xx 순이라는 것 뿐이다. 때문에, 이 함수가 언제 참을 반환하는지 알 수 없다. 대신 다음과 같이 명명하면 훨씬 더 명확해진다.

bool isInsideCircle(int y, int x, int cy, int cx, int cr);

5. 코드와 데이터를 분리하기

날짜를 다루는 프로그램을 작성하는데, 날짜를 출력할 때 월을 숫자가 아니라 영문 이름으로 출력해야 한다고 하자. 프로그래밍을 처음 배운 사람들이 하는 큰 실수는 다음과 같은 열두 줄 짜리 함수를 짜는 것이다.

string getMonthName(int month) { if(month==1) return "January"; if(month==2) return "February"; ... return "December"; }

경험이 생기고 상식이 쌓이고 나면 이런 코드를 피하게 된다. 코드의 논리와 상관 없는 데이터는 가능한 한 분리하는 것이 좋다. 예를 들어 각 월의 영어 이름을 다음과 같은 테이블로 만들 수 있다.

const string monthName[] = {"January", "February", "March", "April", ..., "December"};

이런 방식은 항상 코드의 양을 줄여서 실수를 하지 않게 도와준다. 같은 프로그램에서 각 달에 포함된 날의 수를 사용하고 싶다면 다음과 같은 정수 배열을 선언하면 된다.

//윤년을 별도로 처리하지 않을 경우 int daysIn[12] = {31, 28, 31, 31, 31, 30, 31, 31, 30, 31, 30, 31};

🔨POPULAR MISTAKES

1. 스택 오버플로(Stack Overflow) 조심하기

프로그램의 실행 중 콜 스택(Call Stack)이 오버플로해서 프로그램이 강제종료 되는 것은 흔히 하는 실수이다. 스택 오버플로는 대개 재귀 호출의 깊이가 너무 깊어져서 온다. 스택 최대 크기는 컴파일이나 실행 시에 설정할 수 있고 기본 값이 언어나 아키텍처 등에 따라 매우 다르기 때문에 대회에서 사용하는 환경의 스택 허용량에 대해 알아 둘 필요가 있다.
C++의 경우에는 지역변수로 선언한 배열이나 클래스 인스턴스가 기본적으로 스택 메모리를 사용하기 때문에 특히나 스택 오버플로를 조심해야 한다.
배열 등의 큰 지역 변수를 스택에 잡으면 재귀 호출이 몇 번 없어도 곧장 스택 오버플로가 나기 쉽다.
때문에, 참가자들은 자동으로 힙에 메모리를 할당하는 STL 컨테이너를 사용하거나 전역 변수를 사용하곤 한다.


2. 산술 오버플로(Arithmetic Overflow)

예를 들어, 두 개의 32비트 부호 있는 정수를 입력받아 이 둘의 최소공배수를 반환하는 함수를 생각해보자. 두 값 a와 b의 최소공배수 lcm(a, b)는 두 수의 최대공약수 gcd(a, b)를 이용해 다음 식으로 구할 수 있다.

lcm(a, b) = a * b / gcd(a, b)

이것을 코드로 옮기면 다음과 같다.

int gcd(int a, int b); //두 수의 최대공약수를 반환한다. int lcm(int a, int b) { return (a * b) / gcd(a, b); }

하지만, lcm(50000, 100000)을 계산해보면, 엉뚱한 값인 14,100이 나오게 된다. 왜냐하면 계산의 중간 값이 32비트 정수 범위를 넘어간다는 데에 있다. 이 경우, aba * b 의 값은 51095 * 10^9 가 되는데, 이 값은 부호 있는 32비트 정수형의 최대치인 2,147,438,647을 가뿐히 넘어간다. 이 때, C++는 우리에게 아무 경고도 하지 않고 이 값의 마지막 32비트만을 취해서 이 값이 705,032,704인 것마냥 생각하고, 이 값을 50,000으로 나눈 14,100을 반환하는 것이다.

오버플로를 피해가기 위해서는 더 큰 자료형을 쓰면 된다. 32비트 정수형의 최대치는 훌쩍 넘어가지만, 64비트 정수형을 사용하면 쉽게 저장할 수 있는 경우가 많다. 앞에서 예로 든 lcm()은 이 방법으로 쉽게 고칠 수 있다. 51095 * 10^9 는 32비트 정수형의 최대치는 훌쩍 넘어가지만, 64비트 정수형을 사용하면 쉽게 저장할 수 있다. 그러니 해당 수식에서 aabb 중 하나를 64비트 정수형으로 캐스팅해주자. 그렇게 되면 자료형의 프로모션에 의해서 보다 넓은 범위를 갖는 자료형으로 변환된다.

int lcm(int a, int b) { return (a * (long long)b) / gcd(a, b); }

혹은 오버플로가 나지 않도록 애초에 연산의 순서를 바꿔버리는 것도 방법이 되겠다.

int lcm(int a, int b) { return a * (b / gcd(a, b)); }

cf) 자료형의 프로모션이란?
사칙연산이나 대소 비교 등의 이항 연산자들은 두 개의 피연산자를 받는다. 만약 피연산자의 자료형이 다르거나 범위가 너무 작은 경우 컴파일러들은 대개 이들을 같은 자료형으로 변환해서 계산하는데, 이를 프로모션이라고 한다.

  1. 한쪽은 정수형이고 한쪽은 실수형일 경우: 정수형이 실수형으로 변환된다.
  2. 양쪽 다 정수형이거나 양쪽 다 실수형일 경우: 보다 넓은 범위를 갖는 자료형으로 변환된다.
  3. 양쪽 다 int형보다 작은 정수형일 경우: 양쪽 다 int형으로 변환된다.
  4. 부호 없는 정수형과 부호 있는 정수형이 섞여 있을 경우: 부호 없는 정수형으로 변환된다.

3. 너무 느린 입출력 방식 선택

대부분의 프로그래밍 언어에서는 텍스트를 입출력할 수 있는 다양한 방법을 제공한다.
예를 들어, C++에서는 gets()를 이용해 모든 입력을 문자열 하나로 읽어들인 뒤 파싱할 수도 있고, cin 등의 고수준 입력 방식을 사용할 수도 있다. 대개의 경우 고수준 입력방식을 이용하면 코드가 간단해지지만, 이에 따른 속도 저하 또한 클 수 있다! (cin같은 고수준 입출력 방식이 저수준 방식보다 두 배 이상 느린 경우도 심심찮게 볼 수 있다고 한다...)
따라서, 입출력의 양이 많다면 어떤 입출력 방식을 선택하는지가 프로그램의 정답 여부를 충분히 바꿔놓을만한 요소가 되므로 어느 쪽이 빠른지를 미리 점검해 두자!