타입 코드 처리하기

  • if 문에서 else를 사용하지 말것switch를 사용하지 말것으로 이른 바인딩 제거하기

  • 클래스로 타입 코드 대체클래스로의 코드 이관으로 if문 제거하기

  • 함수 전문화로 문제가 있는 일반성 제거하기

  • 인터페이스에서만 상속받을 것으로 코드 간 커플링(결합) 방지하기

  • 함수 인라인화삭제 후 컴파일하기를 통한 불필요한 함수 제거

요약

  • else와 switch는 프로그래밍 가장자리에만 있어야 한다.

  • else와 switch는 모두 낮은 수준의 제어 흐름 연산자이다.

  • 애플리케이션 핵심에서는 클래스로 타입 코드 대체클래스로의 코드 이관 리팩터링 패턴을 사용하여 switch와 연속된 else if 구문을 높은 수준의 클래스와 메서드로 대체해야 한다.

  • 지나치게 일반화된 메서드는 리팩터링을 방해할 수 있다. 이런 경우 불필요한 일반성을 제거하기 위해 메서드 전문화 리팩터링을 사용

  • 추상 클래스와 클래스 상속은 불필요하게 긴밀한 커플링을 발생시킨다.

  • 리팩터링 후 정리하기 위한 두가지 리팩터링 패턴 1. 메서드 인라인화, 2. 삭제 후 컴파일하기로 더이상 가독성에 도움이 되지 않는 메서드 제거

간단한 if 문 리팩토링

#1. if 문에서 else를 사용하지 말 것

프로그램에서 이해하지 못하는 타입(형)인지 검사하지 않는한 if 문에서 else를 사용하지 않기

독립된 if 문은 check(검사), if-else 문은 의사결정(decision)으로 간주한다.

Decision

window.addEventListener("keydown", e => {
  if (e.key === LEFT_KEY || e.key === "a") inputs.push(Input.LEFT);
  else if (e.key === UP_KEY ||e.key === "w") inputs.push(Input.UP);
  else if (e.key === RIGHT_KEY ||e.key === "d") inputs.push(Input.RIGHT);
  else if (e.key === DOWN_KEY ||e.key === "s") inputs.push(Input.DOWN);
})

사용자의 입력을 받거나 데이터베이스에서 값을 가져오는 등 앱 외부에서 입력을 받는 프로그램의 경계에서 발생하는 경우 문제가 되지 않는다.

  • 외부의 데이터 타입을 내부에서 제어 가능한 데이터 타입으로 매핑하는 것이 중요

  • 조건절에서 두 가지 데이터 타입(KetboardEvent, string) 중 어느것도 우리가 결정할 수 없음

  • 이러한 else-if 체인 구문은 데이터의 입출력에 직접 연결되어야하고, 앱의 나머지 부분과는 분리되야함

Check

사용자가 이해할 수 있는 오류를 발생시킨다.

// Before
function average(ar: number[]) {
  if (size(ar) === 0)
    throw "Empty array not allowed";
  else
    return sum(ar) / size(ar);
}

// After
function assertNotEmpty(ar: number[]) {
  if (size(ar) === 0)
    throw "Empty array not allowed";
}

function average(ar: number[]) {
  assertNotEmpty(ar);
  return sum(ar) / size(ar);
}

스멜

  • 이 규칙은 이른 바인딩(early binding)과 관련 있음

    • 프로그램을 컴파일할 때 if-else 같은 의사결정 동작은 컴파일 시 처리되어 앱에 고정며 수정 불가

    • 반대로 늦은 바인딩(late binding)은 코드가 실행되는 순간에 동작이 결정됨

  • 이른 바인딩은 if 문을 수정해야 변경할 수 있기 때문에 추가에 의한 변경을 방해한다.

    • 역으로 늦은 바인딩은 추가를 통한 변경이 가능하게 한다.

늦은 바인딩은 클래스 타입 코드 대체, 전략 패턴의 도입이라는 리팩터링 패턴을 다룰때 확인

의도

  • if는 조건 연산자로 흐름을 제어한다.

    • 다음에 실행할 코드를 결정

  • 객체지향 프로그래밍에는 객체라는 훨씬 더 강력한 제어 흐름 연산자가 존재

  • 인터페이스를 사용한 두 가지 다른 구현이 있는 경우 인스턴스화하는 클래스에 따라 실행할 코드를 결정할 수 있음

  • 이 규칙은 더 강력하고 더 유연한 도구인 객체를 사용하는 방안을 찾게한다.

리팩터링 패턴: 클래스로 타입 코드 대체

열거형(switch, esle if)을 인터페이스로 변환하고 열거형의 값들을 클래스가 된다.

  • 각 값에 속성을 추가하고 해당 특정 값과 관련된 기능을 특성에 맞게 만들 수 있다.

  • 열거형의 값을 클래스로 변환할 때는 다른 열거 값을 고려하지 않고 해당 값과 관련된 기능을 그룹화 가능

    • 기능을 해당 값의 특징에 맞게 만들기 가능

  • 열거형에 새 값을 추가하는 것은 수많은 파일에 걸쳐 해당 열거형과 연결된 로직들을 확인해야하는 반면

  • 인터페이스를 구현한 새로운 클래스를 추가하는 것은 해당 클래스에 메서드의 구현이 필요할 뿐, 다른 코드를 수정하지 않아도 됨

클래스로 코드 이관과 함께 사용해서 추가를 통한 변경으로 이어진다.

타입 코드

정수 타입 또는 일치 비교 연산자 === 를 지원하는 모든 타입은 타입 코드로 볼수 있다.

  • 가장 일반적으로 enum을 사용

  • 타입 코드 또한 열거형이 아닌 형태로 변환 가능

const SMALL = 33;
const MEDIUM = 37;
const LARGE = 42;

// After
enum TShirtSizes = {
  SMALL = 33;
  MEDIUM = 37;
  LARGE = 42;
}

절차

  1. 임시 이름을 가진 새로운 인터페이스를 도입

    1. 인터페이스에는 열거형(enum)의 각 값에 대한 메서드가 있어야 한다.

  2. 열거형의 각 값에 해당하는 클래스를 만듦

    1. 클래스에 해당하는 메서드를 제외한 인터페이스의 모든 메서드는 false를 반환해야 함

  3. 열거형의 이름을 다른 이름으로 변경

    1. 컴파일러가 열거형을 사용하는 모든 코드에 오류를 발생시키기 위함

  4. 타입을 이전 이름에서 임시 이름으로 변경하고 일치성 검사를 새로운 메서드로 대체

  5. 남아있는 열거형 값에 대한 참조 대신 새로운 클래스를 인스턴스화하여 교체

  6. 오류가 더이상 없다면 인터페이스의 이름을 모든 위치에서 영구적인 것으로 바꿈

// Before
enum TrafficLight {
  RED, YELLOW, GREEN
}
const CYCLE = [TrafficLight.RED, TrafficLight.Yellow, TrafficLight.GREEN]
function updateCarForLight(current: TrafficLight) {
  if (current === TrafficLight.RED)
    car.stop()
  else
    car.drive()
}

// After 
//#1.새로운 인터페이스
interface TrafficLight2 {
  isRed(): boolean
  isYellow(): boolean
  isGreen(): boolean
}
//#2.새로운 클래스들
class Red implements TrafficLight2 {
  isRed() {return true}
  isYellow(): {return false}
  isGreen(): {return false}
}
class Yellow implements TrafficLight2 {
  isRed() {return false}
  isYellow(): {return true}
  isGreen(): {return false}
}
class Green implements TrafficLight2 {
  isRed() {return false}
  isYellow(): {return false}
  isGreen(): {return true}
}
//#3.기존 열거형 이름 바꾼후 에러 발생시키기
enum RawTrafficLight {
  RED, YELLOW, GREEN
}

//#4.임시 이름으로 변경 일치 여부 검사
function updateCarForLight(current: TrafficLight2) {
  if (current.isRed())
    car.stop();
  else
    car.drive();
}

//#5 열거형 값에 대한 나머지 참조를 새 클래스 인스턴스로 교체
const CYCLE =[new Red(), new Yellow(), new Green()];
//#6 더이상 오류가 없다면 인터페이스 이름을 모든 위치에서 영구적인 이름으로 변경
interface TrafficLight {...}

이 패턴은 자체적으로 많은 가치를 가지진 않지만 나중에 환상적인 개선을 가능하게 한다.

모든 값에 대한 메서드를 갖는 것도 스멜이지만, 이것은 하나의 스멜을 다른 스멜로 대체한 것

is로 시작하는 메서드들은 일시적인 것이고 오래 사용할 것은 아니라는 점에 유의

클래스로 코드 이관하기

  • handleInput 의 모든 조건은 매개변수 input과 관련있고, 이는 해당 클래스에 있어야함을 의미

class Right implements Input {
  //...
  handleInput() {
    if (this.isLeft())
      moveHorizontal(-1);
    else if (this.isRight())
      moveHorizontal(1);
    else if (this.isUp())
      moveHorizontal(-1);  
    else if (this.isDown())
      moveHorizontal(1);  
  }
}

//# 새로운 인터페이스
interface Input {
  //...
  handle(): void
}

//# 네가지 클래스 모두에서 handleInput 메서드 변경  
class Right implements Input {
  //...
  handle() { moveHorizontal(1); }
}

function handleInput(input: Input) {
  input.handle()
}

Result

//Before
function handleInput(input: Input) {
  if (input.isLeft())
    moveHorizontal(-1);
  else if (input.isRight())
    moveHorizontal(1);
  else if (input.isUp())
    moveHorizontal(-1);  
  else if (input.isDown())
    moveHorizontal(1);  
  }
}

//After
function handleInput(input: Input) {
  input.handle();
}

interface Input {
  // ...
  handle(): void
}

class Left implements Input {
  //...
  handle() { moveHorizontal(-1); }
}

클래스로의 코드 이관

이 패턴은 기능을 클래스로 옮기기 때문에 클래스로 타입 코드 대체 패턴의 자연스러운 연장선

  • 결과적으로 if 구문이 제거되고 기능이 데이터에 더 가까이 이동한다.

  • 특정 값과 연결된 기능이 값에 해당하는 클래스로 이동하기 때문에 이는 불변속성을 지역화하는데 도움된다.

  • 가장 단순한 형태로, 항상 메서드 전체를 클래스로 옮긴다고 가정

리팩터링 패턴: 메서드의 인라인화

  • 프로그램에서 더 이상 가독성에 도움이 되지 않는 메서드를 제거

  • 메서드에서 이를 호출하는 모든 곳으로 코드 옮기기 -> 안전한 방법

  • 메서드의 인라인화를 수행할 때는 모든 호출 측을 수정하여 원래의 메서드를 제거한다.

인라인화해서는 안되는 메서드

const NUMBER_BITS = 32;
function absolute(x: number) {
  return (x ^ x >> NUMBER_BITS-1) - (x >> NUMBER_BITS-1);
}
  • 목적을 위해 낮은 수준의 연산에 의존하며 메서드로 존재하는 것이 가독성에 도움이 되기에 인라인해서는 안됨

    • 이 경우 인라인하면 '작업은 동일한 추상화 수준에 있어야한다'에 반하는 스멜이 생김

$ 기호는 _처럼 다른 문자들과 동일하게 취급된다. 따라서 변수명에 사용될 수 있고 특별한 의미가 없다

긴 if 문의 리팩토링

메서드 전문화

  1. 전문화하려는 메서드 복제

  2. 메서드 중 하나의 이름을 새로 사용할 메서드의 이름으로 변경하고 전문화하려는 매개변수를 제거(또는 교체)

  3. 매개변수 제거에 따라 메서드를 수정해서 오류 제거

  4. 이전의 호출을 새로운 것을 사용하도록 변경

//Before
function canMove(start: Tile, end: Tile, dx: number, dy: number) {
  return dx * abs(start.x - end.x) === dy * abs(start.y, end.y)
      || dy * abs(start.x - end.x) === dx * abs(start.y - end.y);
}
//...
if (canMove(start, end, 1, 0))//룩
//...
if (canMove(start, end, 1, 1))//비숍
//...
if (canMove(start, end, 1, 2))//나이트
//...

//After
//#1. 전문화하려는 메서드 복제
//#1-1. 메서드 중 하나를 새로운 이름으로 바꾸고 전문화할 매개변수를 제거(또는 교체)
function canMove(start: Tile, end: Tile, dx: number, dy: number) {
  return dx * abs(start.x - end.x) === dy * abs(start.y, end.y)
      || dy * abs(start.x - end.x) === dx * abs(start.y - end.y);
}

function rookMove(start: Tile, end: Tile) {
  return 1 * abs(start.x - end.x) === 0 * abs(start.y, end.y)
      || 0 * abs(start.x - end.x) === 1 * abs(start.y - end.y);
}
//#2. 이전의 호출을 새로운 것으로 변경
if (rookCanMove(start, end))
  • 메서드를 전문화함으로써 일반화를 제거하고 훨씬 더 이해하기 쉬워짐

Switch가 허용되는 유일한 경우

let rawMap: RawTile[][] = [[...], [...], ...];
let map: Tile2[][];

function assertExhausted(x: never): never {
  throw new Error("Unexpected object: " + x);
}

function transformTile(tile: RawTile) {
  switch (tile) {
    case RawTile.AIR: return new Air();
    //...
    default: assertExhausted(tile);
  }
}
// map 전체를 매핑하는 새로운 메서드
function transformMap() {
  map = new Array(rawMap.length);
  for (let y=0; y < rawMap.length; y++) {
    map[y] = new Array(rawMap[y].length);
    for(let x=0; x < rawMap[y].length; x++) {
      map[y][x] = transformTile(rawMap[y][x]);
    }
  }
}

window.onload = () => {
  transformMap();
  gameLoop();
}

Previous긴 코드 조각내기 열거형은 자바에서와 같이 클래스가 아닌 C#에서와 같은 숫자에 대한 명칭이다.

따라서 숫자와 열거형 간의 변환이 필요하지 않으며 이전 코드와 같이 열거형 인덱스를 사용하면 된다.

transformTile은 다섯 줄 제한을 위반하낟. 또한 Switch를 사용하지 말것이라는 또다른 규칙은 타입스크립트의 트릭을 사용해 예외 케이스로 겨우 피해간다.

Switch를 사용하지 말 것

default 케이스가 없고 모든 case에 반환 값이 있는 경우가 아니라면 switch를 사용하지 말자.

  • switch는 각각 버그로 이어지는 두 가지 '편의성'을 허용하기 때문에 문제가 발생한다.

    • 1. switch로 case를 분석할 때 모든 값에 대한 처리를 실행할 필요가 없다.

      • 이를 위해 switch는 default 키워드를 지원 (default로 여러 값을 중복없이 지정 가능)

    • 2. switch를 사용하는 경우 무엇을 처리할지와 무엇을 처리하지 않을지는 이제 불변속성

      • 그러나 기본값이 지정된 다른 경우와 마찬 가지로 새로운 값을 추가할 때 이러한 불변속성이 여전히 유효한지 컴파일러를 통해 판단할 수 없게 된다.

      • 컴파일러 입장에선 우리가 새로 추가한 값의 처리를 잊은건지, default에 지정하고자 한 것인지를 구분할 방법이 없다.

    • 3. break 키워드를 만나기 전까지 케이스를 연속해서 실행하는 풀스루(fall-through) 로직이다.

      • break 키워드를 누락한것을 알아채지 못하기 쉽다.

  • 일반적으로 switch는 멀리하는것이 좋다.

  • 기능을 default에 두지 않는 것

    • 사용하는 언어가 default의 생략을 허용하지 않으면 switch를 사용하지 말아야 한다.

  • 모든 케이스에 return을 지정해서 풀스루 문제를 해결

타입스크립트에서는 컴파일러가 switch 문에서 모든 열거 값을 매핑했는지 확인할 수 있기 때문에 switch 문이 특별히 유용하다.

// 컴파일러가 모든 값을 매핑했는지 확인할 수 있게 할 경우
// 다섯 줄 제한에 맞게 변환할수 없는 몇 안되는 함수 중 하나
function assertExhausted(x: never): never {
  throw new Error("Unexpected object: " + x);
}
//...
switch (t) {
  case ...: return ...;
  //...
  default: assertExhausted(t)
}

스멜

  • switch는 컨텍스트, 즉 값 X를 처리하는 방법에 초점을 맞춘다.

  • 반대로 클래스에 기능을 밀어 넣을 때는 데이터, 즉 이 값(객체)이 상황 X를 처리하는 방법에 초점을 맞춘다.

  • 컨텍스트에 초점을 맞춘다는 것은 데이터에서 불변속성을 더 멀리 위치시켜 불변속성을 전역화하는 것을 의미

인터페이스 대신 추상 클래스를 사용할 수는 없나?

추상 클래스를 사용하여 코드의 중복을 피할 수 있지만 단점이 존재한다.

인터페이스를 사용하면 이를 통해 도입한 각 새로운 클래스에 대해 개발자는 능동적으로 무엇인가를 해야함

잘못해서 속성을 잊어버리거나, 해서는 안되는 오버라이드를 방지 할 수 있게된다.

즉, 시간이 지나서 내용을 잊어버린 채 작업할 ㄹ때 새로운 클래스를 추가해야할 때 문제가 발생한다. 이러한 개념은 중요해서 추상 클래스를 사용하지 못하도록 인터페이스만 상속받을 것이라는 규칙을 공식화 하기도 한다

규칙: 인터페이스에서만 상속받을 것

상속은 오직 인터페이스를 통해서만 받는다.

  • 단순히 클래스, 추상 클래스가 아닌 인터페이스에서만 상속할 수 있다

추상 클래스의 장점은 메서드의 기본 구현을 제공하고 다른 메서드는 추상화여 중복을 줄이고 코드의 줄을 줄이는데 효과가 있지만 단점이 훨씬 많다.

  • 코드 공유는 결합(커플링)을 유발, 이 경우 커플링은 추상 클래스의 코드가 된다.

  • 한 하위 클래스에서 추상 클래스에서 제공하는 methodA와 methodB 중 하나만 필요하다면 빈 버전으로 재정의 해야함

  • 컴파일러를 통해 재정의가 필요한 메서드인지 잡아내기 어려움

Last updated