이번 장에서 다룰 내용
컴파일러는 코드를 높은 수준의 언어에서 낮은 수준의 언어로 변환할 뿐만 아니라 여러 속성의 유효성을 검사하고 프로그램을 실행할 때 특정 오류가 발생하지 않도록 보장한다.
컴파일러가 우리보다 프로그램의 동작을 더 잘 예측한다는 사실을 받아들이자!
컴파일러는 프로그램이다. 따라서 일관성 같은 특정 작업에 능숙하다.
일반적으로 하나를 여러 번 컴파일한다고 다른 결과가 나오지는 않는다. 반대로 판단과 같은 특정 작업에는 서툴다. 컴파일러는 '의심 가면 물어보라' 라는 일반적인 관용구를 따른다.
기본적으로 컴파일러의 목표는 소스 프로그램과 동일한 다른 언어로 된 프로그램을 생성하는 것이다. 그러나 최신 컴파일러들은 런타임 중에 특정 오류가 발생할 수 있는지도 확인한다.
런타임 동안 어떤 일이 일어날지 정확히 말할 수 없는 이유를 정지문제(halting problem) 이라고 한다. (프로그램을 실행하지 않고는 프로그램이 어떻게 동작할지 알 수 없다.)
정지문제: 일반적으로 프로그램은 근본적으로 예측이 불가능하다.
어떤 프로그램은 분명히 실패하고 거부되지만, 어떤 프로그램은 실패하지 않고 허용될 수도 있다.
정지문제는 컴파일러가 이 둘 사이에서 프로그램을 어떻게 할 것인지를 결정해야 함을 의미한다.
경우에 따라 컴파일러는 런타임 중 실패를 포함해서 예상대로 동작하지 않는 프로그램이라고 해도 허용한다. 다른 경우로,
프로그램이 안전하다고 보장할 수 없는 경우 컴파일러는 프로그램을 허용하지 않는다. 이것을 보수적 분석(conservative analysis) 이라고한다.
우리는 보수적인 분석에만 의존할 수 있다.
보수적인 분석 중 하나는 메서드가 모든 경우에서 반환(return) 되는지 확인하는 것이다. return 문 없이 메서드를 벗어나는 것은 허락되지 않는다.
다음은 런타임 오류처럼 보이지만 never 키워드 때문에 컴파일러는 assertExhausted가 실행되는 경우가 있는지 없는지를 분석한다.
예제 7.2의 예에서 컴파일러는 열거형의 모든 값을 확인하지 않았다는 것을 알아낸다.
enum Color {
RED, GREEN, BLUE
}
function assertExhausted(x:never):never {
throw new Error("Unexpected object: " + x);
}
function handle(t: Color){
if (t === Color.RED) return '##ff0000';
if (t === Color.GREEN) return '#00ff00';
assertExhausted(t); // Color.BLUE를 처리하지 않기 때문에 컴파일러 오류 발생
}
변수가 사용되기 전에 변수에 값이 확실히 할당되었는지 여부를 알아내는 데 능하다.
이 검사는 지역변수 ,특히 if문을 사용해서 지역변수를 초기화하려는 경우에 적용된다.
이때 모든 경로에서 변수를 초기화할 것이라는 보장이 없다.
name이 John인 요소를 찾는 코드를 생각해보자. return 문이 실행될 때 결과 변수가 초기화 되었다는 보장이 없다. 따라서 컴파일러는 이 프로그램을 허용하지 않는다.
//초기화 되지 않은 변수
let result;
for (let i = 0; i < arr.length; i++)
if(arr[i].name === "John")
result = arr[i];
return result;
컴파일러는 데이터를 캡슐화할 때 사용하는 접근 제어에도 탁월하다. 멤버를 비공개(private)로 하면 오용되지 않을 것이라 확신할 수 있다.
불변속성에 민감한 메서드가 있을 경우 비공개로 만들어 보호 할 수 있다.
// 접근 제어로 인한 컴파일러 오류
class Class {
private sensitiveMethod(){
//...
}
}
let c = new Class();
c.sensiviveMethod(); //컴파일러 오류 발생!
가장 강력한 것은 타입검사다. 타입 검사는 변수와 멤버가 존재하는지 확인하는 역할을 한다.
하나의 요소나 요소 다음에 리스트로만 구성되게 해서 비어 있을 수 없는 리스트 데이터 구조를 코드로 만들었다.
// 타입으로 인한 컴파일러 오류
interface NonEmptyList<T>{
head: T;
}
class Last<T> implements NonEmptyList<T> {
constructor(public readonly head: T){ }
}
class Cons<T> implements NonEmptyList<T>{
constructor(public readonly head: T
public readonly tail:NonEmptyList<T>){ }
}
function first<T>(xs:NonEmptyList<T>){
return xs.head;
}
first([]) // 타입오류
null로 메서드를 호출하려고 하면 오류가 발생하기 때문에 위험하다. 일부 도구는 이러한 경우 일부를 감지할 수 있지만, 모든 경우를 감지할 수 없으므로 무작정 도구에 의존할 수 없다. null 검사를 제거 하지 말자.
너무 적게 확인하는 것 보다 너무 많이 확인하는 것이 낫다.
average(null)과 같이 호출하면 프로그램을 중단시킬 수 있음에도 불구하고 예시와 같은 코드가 받아들여진다.
// 잠재적인 Null 역참조 이지만 컴파일러 오류는 없음
function average(arr:number[]){
return sum(arr) / arr.length;
}
컴파일러가 일반적으로 확인하지 않는 것은 0으로 나누기(또는 나머지) 연산이다. 컴파일러는 무언가가 오버플로될 수 있는지 여부도 확인하지 않는다. 이를 산술 오류라고 한다.
// 빈 배열로 호출하면 거의 어떤 컴파일러도 잠재적인 0으로 나누기를 발견하지 못한다.
function average(arr:number[]){
return sum(arr) / arr.length;
}
산술 연산을 할 때 컴파일러는 큰 도움이 되지 않기 떄문에 매우 주의해야한다. 나누는 수가 0이 될 수 없고 오버플로 또는 언더플로를 유발할 만큼 큰 숫자를 더하거나 뺴지 않아야 하며 그래야할 경우 BigInteger과 그 변형을 사용하라.
데이터 구조의 범위 내에 있지 않은 인덱스에 접근하려고 하면 아웃-오브-바운드 오류가 발생한다.
배열에서 첫 번째 소수의 인덱스를 찾는 함수가 있다고 가정해보자. 함수를 사용해서 다음과 같이 첫 번째 소수를 찾을 수 있다.
// 잠재적으로 접근이 범위를 벗어날 수 있지만 컴파일러 오류는 없음
function firstPrime(arr: number[]){
return arr[indexOfPrime(arr)];
}
배열에 소수가 없으면 이 함수는 -1을 반환하므로 아웃-오브-바운드 에러가 발생한다.
프로그램이 실패하는 방법은 아무 일도 일어나지 않고 프로그램이 조용히 반복되는 동안 빈 화면을 응시하게 되는것이다.
아래 예제에서는 현재 위치가 문자열 내인지를 감지하려고한다. 그러나 이전 quotePosition을 indexOf에 대한 두 번째 호출에 전달하는 것을 잊었다. s에 따옴표가 포함된 경우 이것은 무한 루프가 되지만, 컴파일러는 이를 못본다.
// 잠재적 무한 루프지만 컴파일러 오류는 없음
let insideQuote = false;
let quotePosition = s.indexOf("\"")
while(quotePosition >= 0) {
insideQuote = !insideQuote;
quotePosition = s.indexOf("\"");
}
let insideQuote = false;
let quotePosition = s.indexOf("\"")
while(quotePosition >= 0) {
insideQuote = !insideQuote;
quotePosition = s.indexOf("\"", quotePosition + 1);
}
책에서 확인하자.ㅎㅎ p220
변경 가능한 데이터를 공유하는 멀티스레드를 사용하지 말자!
컴파일러의 장점을 활용하고 약점을 피하도록 소프트웨어를 설계해야한다.
switch에서 default를 사용하는지 확인하기 위해 열거형을 사용하는 모든 위치를 찾아야 한다고 가정하자. 열거형 이름에 _handled와 같은 것을 추가함으로써 컴파일러를 이용해서 default를 비롯한 열거형을 사용하는 모든 곳을 찾을 수 있다.
// 컴파일러 오류로 열거형을 사용하는 위치 찾기
enum Color_handled {
RED, GREEN, BLUE
}
function toString(c:Color){
switch (c) {
case Color.RED: return "Red";
default: return "No color";
}
}
예제 클래스들은 모두 문자열이 출력 전에 대문자임을 보장한다.
// 내부
class CapitalizedString {
private value:string;
constructor(str:string){
this.value = capitalize(str);
}
print() {
console.log(this.value);
}
}
// 외부
class CapitalizedString {
public readonly value: string;
constructor(str: string){
this.value = capitalize(str);
}
function print(str: CapitalizedString){
console.log(str.value)
}
}
엄격한 캡슐화를 강제하기 위해 컴파일러의 접근 제어를 사용해서 불변속성을 지역화한다.
데이터를 캡슐화하면 데이터가 기대하는 형태로 유지되는 것을 보장할 수 있다.
// 비공개 도우미 메서드
class Transfer {
constructor(from: string, private amount:number){
this.depositHelper(from, -this.amount);
}
private depositHelper(to:string, amount:number){
let accountId = database.find(to);
database.updateOne(accountId, {$inc: {balance: amount} });
}
deposit(to:string){
this.depositHelper(to, this.amount);
}
}
앞부분에서 비어 있을 수 없는 리스트 데이터 구조를 보았다. 읽기 전용 필드를 사용해서 이를 보장하는데, 이러한 필드는 컴파일러의 확정 할당 분석에 의존하며, 생성자가 종료될 때 값이 할당돼 있어야한다. 다양한 생성자를 지원하는 언어에서도 초기화되지 않은 읽기 전용 필드가 있는 객체는 생성할 수 없다.
// 읽기 전용 필드로 인해 비어 있을 수 없는 리스트
interface NonEmptyList<T>{
head: T;
}
class Last<T> implements NonEmptyList<T>{
constructor(public readonly head: T){}
}
class Cons<T> implements NonEmptyList<T>{
constructor(
public readonly head: T,
public readonly tail: NonEmptyList<T>){}
}
의도적으로 컴파일러와 싸우면서 컴파일러가 제 역할을 하지 못하도록 막는것
주로 타입을 이해하지 못하고, 게으르며, 아키텍처를 이해하지 못한 죄로 인해 발생한다.
타입 검사기는 컴파일러의 가장 강력한 부분이다. 그러므로 그것을 속이거나 무력화하는 것은 최악의 선택이다. 사람들은 타입을 세 가지 다른 방법으로 잘못 사용해서 타입 검사기를 무력화한다.
최근 일부 타입스크립트가 버전 ES6 에서 실행되고 있지만 컴파일러가 ES5로 구성되어 ES6의 모든 메서드에 대해 알지 못하는 문제가 있다. 특히 배열 findIndex를 모른다. 이 문제를 해결하기 위해 개발자는 변수를 any로 형 변환해서 컴파일러가 해당 변수에 대한 모든 메서드 호출을 허용하게 했다.
(<any> arr).findIndex(x => x === 2);
이 메서드가 런타임에 존재하지 않을 가능성은 거의 없어서 위험하지 않지만 설정을 업데이트 하는 것이 더 안전하고 영구적이다!
컴파일러를 속이는 세 번째 방법은 컴파일 시간에서 런타임으로 판단에 필요한 정보를 옮기는 것이다.
10개의 매개 변수가 있는 메서드가 있다고 생각해보자. 이것은 하나의 매개변수를 추가하거나 제거할 때마다 메서드를 호출하는 대신 모든 곳에서 수정해야 하기 때문에 혼란스럽다. 따라서 10개의 매개변수를 사용하는 대신 문자열 키를 가진 값들을 Map에 담아 한 개만 사용하기로 했다. 그러면 코드를 변경하지 않고도 많은 값을 쉽게 추가할 수 있다. 하지만 이것은 판단할 수 있는 정보를 없애버리는 끔찍한 생각이다.
컴파일러는 Map에 어떤 키가 있는지 알 수 없기에 우리가 존재하지 않는 키에 접근 하는지 아닌지 확인 불가능하다.
세 개의 개별 인자를 전달하는 대신 하나의 Map을 전달한다. 그런 다음 get 을 사용해서 값을 가져 올 수 있다.
// 런타임 타입
function stringConstructor(
conf: Map<string, string>,
pars:string[]){
return conf.get("prefix")
+ parts.join(conf.get("jointer"))
+ conf.get("postfix");
}
// 더 안전한 해결책은 이런 특정 필드로 객체를 만드는 것이다.
// 정적 타입
class Configuration {
constructor(
public readonly prefix: string,
public readonly joiner:string,
public readonly postfix:string
){}
}
function stringConstructor(
conf:Configuration,
parts: string[]){
return conf.prefix
+ parts.join(conf.joiner)
+ conf.postfix;
}
인터페이스에서만 상속 받을 것
예제에서 Mammal 에 다른 메서드를 추가하면 이 메서드가 모든 자손 클래스에서 유효한지 수동으로 확인해야한다. 이 때 몇 가지를 놓치거나 확인하는 것을 놓치기 쉽다. 이 코드에서는 오리너구리 (Platypus)를 제외한 대부분의 자손에 대해 작동하는 laysEggs 메서드를 Mammal 슈퍼클래스에 추가했다.
// 상속으로 인한 문제
class Mammal {
laysEggs() {return false
}
class Dolphin extends Mammal { }
class Platypus extends Mammal {
// laysEggs를 오버라이드 해야함
}
예외는 두 가지로 나타난다. 처리를 강제한 예외와 처리가 강제되지 않은 예외이다.
예외가 발생하면 예외를 처리하거나 최소한 호출자에게 예외가 처리되지 않았음을 알려야한다.
다음 예제는 있을 수 있는 일에 대해 예외 처리를 강제하지 않을 경우의 문제 이다,
산술 오류가 발생하기 때문에 합리적으로 입력 배열이 비어있는지 확인한다. 그러나 예외 처리를 강제하지 않기 때문에 호출자는 빈 배열로 메서드를 호출 할 수 있고 프로그램은 여전히 에러가 발생한다.
더 나은 해결책은 처리를 강제한 예외(try, catch를 사용해 명시적으로 처리)를 사용하느 ㄴ것이다.
// 확인되지 않은 예외를 사용
classs EmptyArray extends RuntimeException {}
function average(arr:number[]){
if(arr.length === 0) throw new EmptyArray();
return sum(arr) / arr.length;
}
///...
console.log(average([]));
// 확인된 예외 사용
class Impossible extends RuntimeException { }
class EmptyArray extends CheckedException { }
function average(arr: number[]) throws EmptyArray {
if(arr.length === 0) throw new EmptyArray();
return sum(arr) / arr.length;
}
/// ...
try {
console.log(average(arr));
}catch (EmptyArray e){
throw new Impossible();
}
마이크로 아키텍처는 팀에 영향을 미치지만 다른 팀에는 영향을 주지 않는 아키텍처이다.
getter와 setter로 캡슐화를 깨는 것이다. 그렇게 하면 수신 측과 필드 사이에 결합이 만들어지고 컴파일러가 접근을 제어하지 못하게 된다.
다음 예지의 스택 구현에서는 내부 배열을 노출하여 캡슐화를 무효로 만든다. 이것은 외부 코드가 그것에 의존할 수 있음을 의미한다. 외부 코드는 배열을 변경해서 스택을 변경할 수 있다.
// getter가 있는 열악한 마이크로 아키텍처
class Stack<T>{
private data: T[];
getArray() {return this.data;}
}
stack.getArray()[0] = newBottomElement; // 이 줄은 스택을 변경
문제가 될 수 있는 또 다른 방법은 private 필드를 외부 함수에 인자로 전달하는 경우이다.
// 매개변수가 있는 열악한 마이크로 아키텍처
class Stack<T>{
private data: T[];
printLast() {printFirst(this.data);}
}
function printFirst<T>(arr: T[]){
arr[0] = newBottomElement; // 이 줄은 스택을 변경
}
대신 불변속성을 지역적으로 유지할 수 있도록 this를 전달해야한다.
컴파일러보다 우리가 더 잘 알고 있다는 비생산적인 생각에서 벗어나 컴파일러가 말하는 것에 세심한 주의를 기울일 수 있다.
지역 불변속성은 범위가 제한적이고 명확하기 때문에 관리하기 쉽다. 그러나 컴파일러에게는 동일한 충돌이 발생한다. 우리는 컴파일러가 프로그램에 대해 모르는 것을 알고 있는 것이다.
예제에서는 원소의 수를 세기 위한 데이터 구조를 만들고 있다. 따라서 멤버를 추가할 때 데이터 구조는 추가한 각 멤버의 수를 기록한다. 편의를 위해 추가된 총 멤버의 수도 이록한다.
// 집합의 개수
class CountingSet {
private data: StringMap<number> = { };
private total = 0;
add(element: string){
let c = this.data.get(element);
if(c === undefined)
c = 0;
this.data.put(element, c + 1)
this.total++;
}
}
이 데이터 구조에서 임의의 멤버를 선택하는 메서드를 추가하려고 한다. total보다 작은 임의의 숫자를 선택하고 그것이 배열이었다면 그 위치에 있었을 원소를 반환하는 함수를 만든다. 배열에 저장하지 않았기 때문에 키를 반복하면서 index에서 멤버의 수만큼 빼간다.
// 임의의 멤버 선택(오류)
class CountingSet {
// ...
randomElement(): string { //도달성으로 인한 오류
let index = randomInt(this.total);
for (let key in this.data.keys()){
index -= this.data[key];
if(index <= 0)
return key;
}
}
}
이 메서드는 컴파일러가 도달성 분석에 실패하기 때문에 컴파일되지 않는다. 컴파일러는 total이 데이터 구조에서 총 멤버의 수라는 것을 알지 못하기 떄문에 항상 멤버가 선택되어 반환된다는 것을 알지 못한다. 이것은 이 클래스의 모든 메서드가 종료될 때 참이 유지되는 지역 불변속성이다.
이 경우 Imppassible 예외를 추가해서 오류를 해결 할 수 있다.
class Impossible { }
class CountingSet {
// ...
randomElement(): string {
let index = randomInt(this.total);
for (let key in this.data.keys()){
index -= this.data[key];
if(index <= 0)
return key;
}
throw new Impossible(); //에러를 방지하기 위한 예외 처리
}
}
그러나 이것은 컴파일 에러를 막기 위한 일시적인 조치이다.
이 불변속성이 나중에도 깨지지 않도록 보완 조치를 하지 않았다. 멤버를 제거하는 remove 함수를 만들면서 total을 줄이는 것을 잊었다고 생각해보라. 컴파일러는 그런 위험성 때문에 randomElement 메서드를 허락하지 않는다.
프로그램에 불변속성이 있을 때마다 '이길 수 없다면 같은 편으로 만들어라' 라는 말을 생각해서 다음 내용을 떠올려라!
정상적인 경고의 개수는 0이어야한다.
위험은 사소한 경고 뒤에 숨어있다.
"만일 당신이 이 방에서 가장 똑똑한 사람이라면 당신은 잘못된 방에 있는 것이다."
최신 컴파일러의 일반적인 장점과 약점을 알아야한다. 약점을 피하고 장점을 활용하기 위해 코드를 수정할 수 있다.
컴파일러와 싸우는 대신, 컴파일러를 사용해서 더 높은 수준의 안정성에 도달하는 방법을 배운다.
컴파일러를 신뢰하고, 컴파일러의 출력 결과에 주의를 기울이며, 오염되지 않은 코드베이스를 유지해서 경보 피로를 피하라
코드가 작도할지를 예측할 때 컴파일러에 의존하라.