코드를 오용하기 어렵게 만들라 본 장은 이 중 ‘예측 가능한 코드를 작성하라’와 ‘코드를 오용하기 어렵게 만들라’는 2개와 깊게 관련이 있다.
코드를 작성할 때 다음의 3가지를 반드시 기억해야 한다.
자신에게 분명하다고 해서 다른 사람에게도 분명한 것은 아니다.
다른 개발자는 무의식 중에 당신의 코드를 망가뜨릴 수 있다.
시간이 지나면 자신의 코드를 기억하지 못한다.
따라서, 코드를 작성할 당시에 다른 사람(미래의 자신 포함)에게도 분명하며, 망가뜨리기 어려운 코드를 작성해야 한다.
내가 작성한 코드를 다른 사람들이 파악하는 방법
이름 확인
데이터 타입 확인
문서 읽기
여기까지가 실용적인 방법이고,
직접 묻기
자세한 구현 코드 읽기
위 두 가지는 비현실적이고 비효율적인 방법이다.
코드 계약
서 다른 코드 간의 상호작용을 계약처럼 생각한다.
계약에서 정의한 대로 분명하게 실행되고, 예상과 다르게 실행되는 것이 없어야 한다.
선결 조건 : 코드를 호출하기 전에 사실이어야 하는 것
ex: 실행 전 시스템의 상태, 코드에 공급해야 하는 입력
사후 조건 : 코드가 호출된 후에 사실이어야 하는 것
ex: 시스템이 새로운 상태에 놓였는지, 반환값
불변 사항 : 코드 호출 전후가 동일해야 하는 사항
명백한 사항 / 세부 조항
전기 스쿠터 렌탈 앱을 예로 들자.
핵심 사항
전기 스쿠터 대려
시간 당 10달러
세부 조항
충돌 사고 시 수리 비용 지불
시속 30마일 이상 → 벌금 부과
이중, 시속 30마일 이상에 벌금을 부과하는 항목은 당황스러운 조항일 수 있다.
코드에서는,
핵심 사항
함수와 클래스 이름
인자 타입
반환 타입
Checked exception
세부 조항
주석, 문서
Unchecked exception
핵심 사항으로 조건을 명백하게 하는 것이 좋다.
문서, 주석은 업데이트가 어려워 믿기 힘들다.
Unchecked exception은 처리가 강제되지 않아서 놓칠 수 있다.
세부 조항에 대한 의존성을 최대한 줄이고,
핵심 사항만으로 명확하게 코드를 설명할 수 있도록 하자.
코드 예시
class UserSettings {
UserSettings() { ... }
// 이 함수를 사용해 설정이 로드되기 전에 다른 함수 호출 X
Boolean loadSettings(File location) { ... }
// 이 함수는 loadSettings() 함수 호출 이후, 다른 함수 호출 이전에 호출해야 함
void init() { ... }
// 사용자가 선택한 UI의 색상 반환
// 선택된 색상이 없으면 null 반환
// 설정이 로드 또는 초기화되지 않았으면 null 반환
Color? getUiColor() { ... }
}
클래스, 메서드명을 통해 역할을 충분히 파악할 수 있지만,
loadSettings() → init() → getUiColor() 순서로 실행되지 않으면 문제가 발생한다.
본 책에서는 서로 다른 프로그래밍 언어 간의 비교, 프로그래밍 언어 탄생과 발전의 역사를 통해 학습한다.
2~4장
프로그래밍 언어와 규칙은 필요에 의해 탄생했다.
반복적인 작업을 줄이거나, 가독성을 개선하는 등의 이유로.
언어마다 탄생하게 된 배경, 해결하고자 한 문제가 다르기에, 가지고 있는 특징과 강점 모두 다르다.
언어를 사용하는 사용자는 그러한 배경과 특징을 알고서 자신에게 유리한 언어를 고민하고 사용해야 한다.
연산 순서는 기본적으로 모두 구문 트리(이진 트리 형태)를 따른다. 그것을 표기하는 방식만 다를 뿐이다.
if, else, for, while 등은 모두 C의 goto((어셈블리어에서는 jump))로 처리 가능하다.
하지만 가독성, 반복을 줄이기 위해서 등의 이유로 탄생하고 사용되는 것이다.
5장 함수
함수는 반복되는 큰 조직을 작은 부서로 나누는 것과 비슷하다.
또한 반복되는 작업을 줄이기 위함도 있는데, 반복되는 코드를 재실행하는 비용은 그리 크지 않다.
함수를 호출하려면 다시 돌아갈 위치를 기록할 메모리가 필요하다.
호출한 함수가 또 다른 함수를 호출한다면, 메모리에 하나의 주소만 저장해서는 프로그램을 제대로 실행시킬 수 없다.
그래서 호출한 함수들을 실행하고 돌아갈 주소 값을 스택에 저장한다.
이를 응용하여 재귀 호출도 구현할 수 있다.
깊이가 n으로 깊어지는 반복문은, n이 정해져 있지 않다면 구현할 수 없다.
하지만 재귀 호출을 통해 해결할 수 있다.
HTML처럼 수많은 내포 구조의 데이터를 다루기엔 재귀 호출이 적절하다
6장 예외 처리
코드 실행이 실패하는 경우가 있다.
이 실패를 처리하는 방식은 두 가지로 나뉜다.
반환값으로 확인 후 처리한다.
실패할 경우 점프한다.
C언어를 비롯한 많은 언어들은 1번을, Java를 비롯한 많은 언어들은 2번을 사용한다.
예외를 처리할 때 가장 중요한 것은 놓치지 않는 것이다.
놓치지 않은 예외는 프로그램 종료로 이어질 수 있으므로.
그를 위한 효율적 수단인 Java의 Checked exception은 thorws를 통해 반드시 명시적으로 발생 가능성을 표현하거나 try-catch를 통해 예외를 처리해야 한다. 하지만 하나의 메서드가 thorws할 경우 상위의 모든 메서드가 throws를 해야할 수 있다. 이는 매우 불편하므로 잘 사용되지 않는다.
예외를 처리하는 방식을 선택할 때는 놓치지 않는 것과 코드의 복잡성 두 가지를 모두 고려해야 한다.
7장 이름과 스코프
변수와 함수에 이름을 붙이는 것은, 번호로 무언가를 찾는 것보다 인간에게 직관적이기 때문이다.
그렇게 붙인 이름이 중복되는 경우가 발생한다.
초기에는 하나의 대응표를 사용했는데, 여러가지 문제가 발생했다.
해결하기 위해 나온 방법인 동적 스코프는, 함수 내부에서 전역 변수의 값을 바꿔서 사용하고, 종료 전에 다시 바꿔놓는 방법이다.
하지만 이 방법은, 해당 함수 내부에서 새로 호출한 함수가 전역 변수를 사용할 경우, 바뀐 값을 참조한다는 문제가 발생한다.
이를 다시 해결한 방법이 정적 스코프이다. 함수마다, 정확히는 함수 호출마다 새로운 대응표를 만들고, 함수가 종료되면 대응표를 삭제하는 방식이다.
이 또한 완벽한 방법은 아니지만, 대부분의 상황에서 잘 작동하므로 널리 사용되고 있다.
8장 형
컴퓨터는 숫자를 어떻게 표현할까?
0~9에 대응되는 비트를 할당하고, 각각을 on/off로 표현함으로써 숫자를 표현할 수 있을 것이다.
이는 지극히 비효율적이므로, 자연스러운 과정을 거쳐 이진법을 사용하는 것으로 변모했다.
그러다 소수를 표현할 일이 생겼고, 소수점 자리수를 고정하는 고정 소수점을 사용했었으나,
유연함을 위해 부동 소수점을 사용할 일이 생겼다. 소수점이 어디 위치하는지 자료 자체가 값을 가지고 있는 형태를 의미한다.
그리고 정수와 부동 소수점 자료를 구분하기 위해 형이 탄생했다.
발전된 형태의 형은, 여러 형을 묶어서 새롭게 만들 수도 있게 되었고,
형 자체를 인자로 받아, 내부의 형을 결정할 수도 있게 되었다(C++의 Templete, Java의 Generics 등)
9장 컨테이너와 문자열
배열과 연결 리스트처럼 값을 넣기 위한 상자가 있다.
배열은 인덱스를 통해 값을 조회하기에 O(1)의 시간 복잡도로 값을 조회할 수 있다.
하지만 값을 추가하려면, O(n)의 계산량을 가진다. 특정 인덱스 이후의 값을 모두 복사해야하기 때문.
연결 리스트는 반대로, 값 추가는 O(1)의 시간 복잡도를 가지지만, 값 조회는 O(n)의 시간 복잡도를 가진다.
문자열을 통해 값을 찾는 사전은, 해쉬 테이블과 트리로 대개 구현한다.
해쉬 테이블은 값 조회 시 해쉬 함수 1회만 호출하면 주소값을 알 수 있기에, O(1)의 시간 복잡도를 가진다. 하지만 메모리 소비량이 매우 크다.
트리는 값을 꺼내는 처리가 O(log n)의 시간 복잡도를 가진다.
증가할 가능성이 있는 것과, 그것의 시간 복잡도를 고려해서 자료 구조를 선택해야 한다.
모스 부호로부터 발전되어온 문자 체계는, 아스키를 거쳐 유니코드에까지 이르렀다.
10장 병행 처리
병행 처리는 병렬(하드웨어)과 다르게 소프트웨어적 개념이다.
처리를 변경하는 2가지 방법이 있는데,
하나는 협력적 멀티태스크로, 처리가 일단락되는 시점에 자발적으로 처리 교대를 하는 방법이다.
둘째는 선점적 멀티태스크로, 태스크 스케줄러가 처리를 강제로 중단시킨다. (시간 등의 이유로)
하지만 스레드 세이프하지 않은 상황이 발생할 수 있다.
스레드 세이프하지 않은 상황은
2가지 처리가 변수를 공유
적어도 하나의 처리가 그 변수를 변경
한쪽 처리가 마무리 되기 전에 다른 한쪽의 처리가 끼어들 가능성이 있음
위 세 가지 조건을 모두 만족해야만 한다.
반대로, 어느 한 조건만 막을 수 있어도 스레드 세이프하다.
공유하지 않는 접근법은 너무 엄격해서 구현하기 어려웠다.
변경하지 않는 방법도 존재한다.
끼어들지 않는 방법은, 협력적 스레드를 만드는 것이다.
또는 끼어들면 곤란해지는 처리에 표식을 붙인다. (락, 뮤텍스, 세마포어)
하지만 데드락이 발생할 수 있다.
데이터베이스의 트랜잭션 기법을 메모리에 적용해, 별도의 버전에 변경을 적용하다가, 마지막 단계에서 실제 메모리에 변경을 적용하는 방법이 있다. 하지만 잦은 호출이 일어나면 성능이 나빠질 수 있다.
트랜잭션 메모리 구현은 하드웨어, 소프트웨어를 거쳐 다시 하드웨어로 구현하는 것이 대세가 되었다.
11장 객체와 클래스
객체지향의 탄생에 앞서, 프로그래머에겐 변수와 함수를 합쳐서 모형을 만들고 싶다는 목적이 있었다.
모듈과 패키지로 인수, 초기화 처리 등의 해쉬를 묶는 방법이 있다.
Javascript에서처럼 함수도 해쉬에 넣는 방법이 있다. 함수를 퍼스트 클래스(변수에 대인, 인수로 전달, 반환값으로 사용 등이 가능한 값)로 사용한다는 것이다.
이를 통해 복수 개의 동일하지만 주소가 다른 객체를 생성할 수 있었고, 공유하는 내용을 프로토타입으로 이동시킬 수도 있었다.
그리고 클래스는 새로운 형을 만들고, 조작에 대한 사양을 정의하려고 탄생했다.
결합체를 만드는 생성기
어떤 조작이 가능한지에 대한 사양
코드를 재사용하는 단위
1번은 앞선 사례에서도 사용된 예시이고, 2번은 Java에서 인터페이스로 만들어졌다.
3번은 상속을 통한 것인데, 다음 장에서 다룬다.
12장 상속을 통한 재사용
상속은 3가지 측면으로 접근할 수 있다.
1. 일반화 / 특수화
흔히 말하는 is-a 관계에 해당한다.
2. 공통 부분을 추출
이는 is-a 관계에 해당하지 않는다.
3. 차분 구현
대부분 is-a 관계가 아니며, 상속을 재사용을 위해 사용하고, 변경된 부분만 추가로 구현한다.
리스코프 치환원칙을 지켜야 하지만, 속성이 늘어날 때 상속 관계로부터 리스코프 치환원칙이 지켜지지 않을 수 있다.
다중 상속은 편리하지만 다루기 어려운 도구이다.
코드 재사용에 매우 편리하지만, 메서드나 변수가 충돌하는 문제가 생길 수 있다.
해결책을 짚어보자.
1. 다중 상속을 금지한다. → Java
위임 (조합) → 이는 객체를 상속하는 게 아니라 보유함으로써 중복 코드를 제거한다. 이를 하드코딩하는 것이 아니라, 설정 파일을 통해 실행 시에 주입하는 것이 편리하다는 발상에서 ‘의존성 주입’이 탄생했다.
Java는 다중 상속을 금지하지만, Interface에 한해서는 가능하다.
2. 메소드 해결 순서를 고민한다.
상속받은 클래스의 메소드 중 어느 것을 사용할 지 해결하는 알고리즘을 사용하는 것이다. DFS, C3 선형화 등의 방법이 사용된다.
3. 처리를 섞는다.
Python의 Mixin처럼 재사용하고 싶은 기능만을 모은 작은 클래스를 만들어, 해당 기능을 추가하고 싶은 클래스에 섞어 넣는다.
4. 트레이트(Trait)
클래스는 2가지 상반되는 역할이 있다.
첫째는, 인스턴스를 만들기 위한 것.
둘째는, 재사용 단위.
Java에서는 클래스를 첫째의 역할로 주로 사용하고, 둘째의 역할로는 사용하는 것을 권장하지 않는다.
이 중 둘째의 역할에 특화된 작은 구조(메소드 묶음)가 트레이트이다.
어느 것이 정답인지는 모른다. 그리고 각각의 상황에 따라 정답이 달라질 수도 있다.
트레이트는 다양한 언어에서 도입되고 있다. 이후에 가장 유망한 기술로 자리잡을 수도 있다.
하지만 10년, 20년 후 보다 좋은 해결책이 고안되어, 동적 스코프 같이 잊혀질 수도 있다.
단, 클래스가 ‘재사용 단위’, ‘인스턴스 생성기’로서의 역할을 모두 가지고 있고, 그것은 상반된 것이라는 ‘트레이트’의 생각 방식은 매우 훌륭해 보인다고 저자는 말한다.