기본형 집착(Primitive Obsession)

박상훈·2022년 8월 12일
0
어플리케이션이 다루는 도메인에 필요한 기본 타입을 만들지 않고 언어가 제공하는 기본 타입을 사용하는 경우가 많음
기본형으로는 단위 또는 표기법을 표현하기 어려움

기본형을 객체로 바꾸기(Replace Primitive with Object)

  • 개발 초기에는 기본형으로 표현한 데이터가 나중에 관련있는 다양한 기능을 필요로 하는 경우
    • 문자열로 표현하던 전화번호의 지역 코드가 필요한 경우
    • 숫자로 표현하던 온도의 단위를 변환하는 경우

Order 클래스에서 사용하는 기본 타입의 변수를 객체로 바꾼다
Priority 클래스를 생성하고 역할에 맞는 데이터 필드, 필요한 기능들을 추가한다
Order 클래스에서 생성자 체인(Constructor Chaining)을 활용하여 문자열을 받는 생성자 호출시
Priority 객체를 포함하는 생성자 호출로 체이닝한다

before

class Order {
    private String priority;
    // ...생성자, getter
}

class OrderProcessor {
    public long numberOfHighPriorityOrders(List<Order> orders) {
        return orders.stream()
                .filter(o -> o.getPriority() == "high" || o.getPriority() == "rush")
                .count();
    }
}

after

class Priority {
    private String value;
    private List<String> legarValues = List.of("low", "normal", "high", "rush");
  
    public Priority(String value) {
        if (legarValues.contains(value)) {
            this.value = value;
        } else {
            throw new IllegalArgumentException("illegal value for priority");
        }
    }
  
    @Override
    public String toString() {
        return this.value;
    }
  
    private int index() {
        return this.legarValues.indexOf(this.value);
    }
  
    public boolean higherThan(Priority other) {
        return this.index() > other.index();
    }
}

class Order {
    private Priority priority;
  
    public Order(String priority) {
        this(new Priority(priority));
    }
  
    public Order(Priority priority) {
        this.priority = priority;
    }
  
    public Priority getPriority() {
        return priority;
    }
}

class OrderProcessor {
    public long numberOfHighPriorityOrders(List<Order> orders) {
        return orders.stream()
                .filter(o -> o.getPriority().higherThan(new Priority("normal")))
                .count();
    }
}

타입 코드를 서브클래스로 바꾸기(Replace Type Code with Subclass)

  • 비슷하지만 다른 것들을 표현해야 하는 경우, 문자열, 열거형, 숫자
    • 주문 타입으로 일반 주문, 빠른 주문 ...
    • 직원 타입으로 엔지니어, 매니저 ...
  • 타입을 서브클래스로 바꾸는 계기
    • 조건문을 다형성으로 표현할 수 있을 때 서브클래스 생성 및 조건부 로직을 다형성으로 바꾸기
    • 특정 타입에만 유효한 필드가 있을 때 서브클래스 생성 및 필드 내리기

첫 번째 케이스 해당 객체를 상속받은 서브클래스로 해결이 가능한 경우
Employee 에 있는 타입 문자열들을 각 각의 하위클래스로 생성하고 각 역할에 해당하는 타입을 리턴

before

class Employee {
    private String name;
    private String type;
  
    public Employee(String name, String type) {
      this.validate(type);
      this.name = name;
      this.type = type;
    }
  
    private void validate(String type) {
      List<String> legalTypes = List.of("engineer", "manager", "salesman");
      if (!legalTypes.contains(type)) {
        throw new IllegalArgumentException(type);
      }
    }
  
    // ...getType, toString
}

after

abstract class Employee {
    private String name;
  
    public Employee(String name) {
      this.name = name;
    }
  
    public static Employee createEmployee(String name, String type) {
      return switch (type) {
        case "engineer" -> new Engineer(name);
        case "manager" -> new Manager(name);
        case "salesman" -> new Salesman(name);
        default -> throw new IllegalArgumentException(type);
      };
    }
  
    public abstract String getType();
  
    // ...toString
}

class Engineer {
    // ...생성자, getType => return "engineer" 
}
class Manager {
    // ...생성자, getType => return "manager"
}
class Salesman {
    // ...생성자, getType => return "salesman"
}

두 번째 케이스 해당 객체를 상속받은 서브클래스가 이미 있는 경우
Type 부모클래스를 별도로 생성하여 해당 클래스를 상속받는 하위클래스들을 생성
해당 객체에서 Type 클래스에 더 적합한 역할의 함수들은 이동

before

class Employee {
    private String name;
    private String type;
  
    public Employee(String name, String type) {
      this.validate(type);
      this.name = name;
      this.type = type;
    }
  
    private void validate(String type) {
      List<String> legalTypes = List.of("engineer", "manager", "salesman");
      if (!legalTypes.contains(type)) {
        throw new IllegalArgumentException(type);
      }
    }
  
    public String capitalizedType() {
      return this.type.substring(0, 1).toUpperCase() + this.type.substring(1).toLowerCase();
    }
  
    // ...toString
}

after

class EmployeeType {
    public String capitalizedType() {
        return this.toString().substring(0, 1).toUpperCase() + this.toString().substring(1).toLowerCase();
    }
}
class Engineer {
    // ...toString "engineer"
}
class Manager {
    // ...toString "manager"
}
class Salesman {
    // ...toString "salesman"
}

class Employee {
    private String name;
    private EmployeeType type;
  
    public Employee(String name, String type) {
      this.name = name;
      this.type = this.employeeType(type);
    }
  
    private EmployeeType employeeType(String typeValue) {
      return switch (typeValue) {
        case "engineer" -> new Engineer();
        case "manager" -> new Manager();
        case "salesman" -> new Salesman();
        default -> throw new IllegalArgumentException(typeValue);
      };
    }
  
    public String capitalizedType() {
      return this.type.capitalizedType();
    }
  
    // ...toString
}

조건부 로직을 다형성으로 바꾸기(Replace Conditional with Polymorphism)

  • 복잡한 조건식을 상속과 다형성을 사용해 코드를 명확하게 분리
  • switch 문을 사용해서 타입에 따라 각기 다른 로직을 사용하는 코드
  • 기본 동작과 특수한 기능이 섞여있는 경우에 상속구조를 만들어 기본 동작은 상위클래스 특수한 기능은 하위 클래스로 차이점 강조

Employee 클래스를 상위 클래스로 type 을 하위클래스로 생성한다
switch case 의 조건부 로직을 하위클래스의 다형성을 이용한 로직으로 변경한다

before

class Employee {
    private String type;

    private List<String> availableProjects;

    public Employee(String type, List<String> availableProjects) {
        this.type = type;
        this.availableProjects = availableProjects;
    }

    public int vacationHours() {
        return switch (type) {
            case "full-time" -> 120;
            case "part-time" -> 80;
            case "temporal" -> 32;
            default -> 0;
        };
    }

    public boolean canAccessTo(String project) {
        return switch (type) {
            case "full-time" -> true;
            case "part-time", "temporal" -> this.availableProjects.contains(project);
            default -> false;
        };
    }
}

after

class Employee {
    private List<String> availableProjects;

    public Employee(List<String> availableProjects) {
        this.availableProjects = availableProjects;
    }

    public Employee() {};

    protected abstract int vacationHours();

    public boolean canAccessTo(String project) {
        return this.availableProjects.contains(project);
    }
}

class FullTimeEmployee {
    public FullTimeEmployee(List<String> availableProjects) {
        super(availableProjects);
    }

    @Override
    public int vacationHours() {
        return 120;
    }
}

class PartTimeEmployee {
    public PartTimeEmployee(List<String> availableProjects) {
        super(availableProjects);
    }

    @Override
    public int vacationHours() {
        return 80;
    }
}

class TemporalEmployee {
    public TemporalEmployee(List<String> availableProjects) {
        super(availableProjects);
    }

    @Override
    public int vacationHours() {
        return 32;
    }
}

아래 코드를 보면 동일한 코드로 분기되어 있는 코드가 보인다
this.voyage.zone().equals("china") && this.hasChinaHistory()
china 를 기준으로 하위클래스를 생성하여 코드를 분리할 수 있으며 팩토리를
생성하여 분기에 따라 상위 or 하위 클래스를 생성한다
private 한 행위 중 상속에 필요한 행위들을 protected 로 변경하고 상속받아 재정의한다

before

class VoyageRating {
    private Voyage voyage;
  
    private List<VoyageHistory> history;
  
    public VoyageRating(Voyage voyage, List<VoyageHistory> history) {
        this.voyage = voyage;
        this.history = history;
    }
  
    public char value() {
        final int vpf = this.voyageProfitFactor();
        final int vr = this.voyageRisk();
        final int chr = this.captainHistoryRisk();
        return (vpf * 3 > (vr + chr * 2)) ? 'A' : 'B';
    }
  
    private int captainHistoryRisk() {
        int result = 1;
        if (this.history.size() < 5) result += 4;
        result += this.history.stream().filter(v -> v.profit() < 0).count();
        if (this.voyage.zone().equals("china") && this.hasChinaHistory()) result -= 2;
        return Math.max(result, 0);
    }
  
    private int voyageRisk() {
        int result = 1;
        if (this.voyage.length() > 4) result += 2;
        if (this.voyage.length() > 8) result += this.voyage.length() - 8;
        if (List.of("china", "east-indies").contains(this.voyage.zone())) result += 4;
        return Math.max(result, 0);
    }
  
    private int voyageProfitFactor() {
        int result = 2;
    
        if (this.voyage.zone().equals("china")) result += 1;
        if (this.voyage.zone().equals("east-indies")) result +=1 ;
        if (this.voyage.zone().equals("china") && this.hasChinaHistory()) {
            result += 3;
            if (this.history.size() > 10) result += 1;
            if (this.voyage.length() > 12) result += 1;
            if (this.voyage.length() > 18) result -= 1;
        } else {
            if (this.history.size() > 8) result +=1 ;
            if (this.voyage.length() > 14) result -= 1;
        }
    
        return result;
    }
  
    private boolean hasChinaHistory() {
        return this.history.stream().anyMatch(v -> v.zone().equals("china"));
    }
}

record Voyage(String zone, int length) {}

record VoyageHistory(String zone, int profit) {}

after

class VoyageRating {
    protected Voyage voyage;

    protected List<VoyageHistory> history;

    public VoyageRating(Voyage voyage, List<VoyageHistory> history) {
        this.voyage = voyage;
        this.history = history;
    }

    public char value() {
        final int vpf = this.voyageProfitFactor();
        final int vr = this.voyageRisk();
        final int chr = this.captainHistoryRisk();
        return (vpf * 3 > (vr + chr * 2)) ? 'A' : 'B';
    }

    protected int captainHistoryRisk() {
        int result = 1;
        if (this.history.size() < 5) result += 4;
        result += this.history.stream().filter(v -> v.profit() < 0).count();
        return Math.max(result, 0);
    }

    private int voyageRisk() {
        int result = 1;
        if (this.voyage.length() > 4) result += 2;
        if (this.voyage.length() > 8) result += this.voyage.length() - 8;
        if (List.of("china", "east-indies").contains(this.voyage.zone())) result += 4;
        return Math.max(result, 0);
    }

    protected int voyageProfitFactor() {
        int result = 2;
        if (this.voyage.zone().equals("china")) result += 1;
        if (this.voyage.zone().equals("east-indies")) result +=1 ;
        result += historyLengthFactor();
        result += voyageLengthFactor();
        return result;
    }

    protected int voyageLengthFactor() {
        return (this.voyage.length() > 14) ? -1 : 0;
    }

    protected int historyLengthFactor() {
        return (this.history.size() > 8) ? 1 : 0;
    }
}

class RatingFactory {
    public static VoyageRating createVoyageRating(Voyage voyage, List<VoyageHistory> history) {
        if (voyage.zone().equals("china") && hasChinaHistory(history)) {
            return new ChinaExperienceVoyageRating(voyage, history);
        } else {
            return new VoyageRating(voyage, history);
        }
    }

    private static boolean hasChinaHistory(List<VoyageHistory> history) {
        return history.stream().anyMatch(v -> v.zone().equals("china"));
    }
}

class ChinaExperienceVoyageRating {
    public ChinaExperienceVoyageRating(Voyage voyage, List<VoyageHistory> history) {
        super(voyage, history);
    }

    @Override
    protected int captainHistoryRisk() {
        int result = super.captainHistoryRisk() - 2;
        return Math.max(result, 0);
    }

    @Override
    protected int voyageProfitFactor() {
        return super.voyageProfitFactor() + 3;
    }

    @Override
    protected int voyageLengthFactor() {
        int result = 0;
        result += historyLengthFactor();
        if (this.voyage.length() > 12) result += 1;
        if (this.voyage.length() > 18) result -= 1;
        return result;
    }

    @Override
    protected int historyLengthFactor() {
        return (this.history.size() > 10) ? 1 : 0;
    }
}
profile
엔지니어

0개의 댓글