클린코드 5, 6장

트곰·2022년 10월 31일
0

5. 형식 맞추기

  • 프로그래머라면 형식을 깔끔하게 맞춰 코드를 짜야 한다.
  • 코드 형식을 맞추기 위한 간단한 규칙을 정하고 그 규칙을 착실히 따라야 한다.
  • 팀으로 일한다면 팀이 합의해 규칙을 정하고 모두가 그 규칙을 따라야 한다.
  • 필요하다면 규칙을 자동으로 적용하는 도구를 활용한다.

형식을 맞추는 목적

  • 코드 형식은 의사소통의 일환이다. 돌아가는 코드가 전문 개발자의 일차적인 의무라고 생각할 수 있지만, 코드 구현 스타일과 가독성 수준은 유지보수 용이성과 확장성에 계속 영향을 미친다.

1. 적절한 행 길이를 유자하라

신문 기사처럼 작성하라

  • 이름은 간단하면서 설명이 가능하게 짓고, 이름만 보고도 올바른 모듈을 살펴보고 있는지 아닌지 판단할 정도로 신경 써서 짓는다.
  • 첫 부분은 고차원 개념과 알고리즘을 설명하고, 아래로 내려올수록 의도를 세세하게 묘사한다. 마지막에는 가장 저차원 함수와 세부 내역이 나온다.

개념은 빈 행으로 분리하라

  • 거의 모든 코드는 왼쪽에서 오른쪽, 위에서 아래로 읽힌다.
  • 각 행은 수식이나 절을 나타내고, 일련의 행 묶음은 완결된 생각 하나를 표현한다. 생각 사이에는 빈 행을 넣어 분리해야 마땅하다.
package fitnesse.wikitext.widgets;

import java.uitl.regex.*;

public class BoldWidget extends ParentWidget {
	public static final String REGXP = "'''.+?'''";
    private static final Patten patten = Patten.compile("'''(.+?)'''",
    	Patten.MULTILINE + Patten.DOTALL
    );
    
    public BoldWidget(ParentWidget parent, String text) throws Exception {
    	super(parent);
        Matcher match = patten.matcher(text);
        match.find();
        addChildWidgets(match.group(1));
    }
    
    public String render() throws Exception {
    	StringBuffer html = new StringBuffer("<b>");
        html.append(childHtml().append("<b>");
        return html.toString();
    }
}
package fitnesse.wikitext.widgets;
import java.uitl.regex.*;
public class BoldWidget extends ParentWidget {
	public static final String REGXP = "'''.+?'''";
    private static final Patten patten = Patten.compile("'''(.+?)'''",
    	Patten.MULTILINE + Patten.DOTALL
    );
    public BoldWidget(ParentWidget parent, String text) throws Exception {
    	super(parent);
        Matcher match = patten.matcher(text);
        match.find();
        addChildWidgets(match.group(1));
    }
    public String render() throws Exception {
    	StringBuffer html = new StringBuffer("<b>");
        html.append(childHtml().append("<b>");
        return html.toString();
    }
}

위와 아래는 동일한 코드인데, 빈 행 유무의 차이만 있다.
빈 행이 없어지면, 코드 가독성이 현저하게 떨어져 암호처럼 보인다.

세로 밀집도

  • 줄바꿈이 개념을 분리한다면, 세로 밀집도는 연관성을 의미한다.
public class ReporterConfig {
	/**
    * 리포터 리스너의 클래스 이름
    */
    private String m_className;

	/**
    * 리포터 리스너의 속성
    */
    private List<Property> m_properties = new ArrayList<Property>();
    public void addProperty(Property property) {
    	m_properties.add(property);
    }
}
public class ReporterConfig 
    private String m_className;
    private List<Property> m_properties = new ArrayList<Property>();
    
    public void addProperty(Property property) {
    	m_properties.add(property);
    }
}

위에 주석이 있는 코드보다, 아래 코드가 '한 눈'에 들어온다.

수직 거리

  • 서로 밀접한 개념은 세로로 가까이 둬야 한다. 물론 두 개념이 서로 다른 파일에 속한다면 규칙이 통하지 않지만, 타당한 근거가 없다면 서로 밀접한 개념은 한 파일에 속해야 마땅하다. 이 것이 바로 protected 변수를 피해야 하는 이유 중 하나다.
  • 밀접한 두 개념은 세로 거리로 연관성을 표현한다.
  • 연관성이란 한 개념을 이해하는 데 다른 개념이 중요한 정도로, 연관성이 깊은 두 개념이 멀리 떨어져 있으면 코드를 읽는 사람이 소스 파일과 클래스를 여기저기 뒤지게 된다.
변수 선언

변수는 사용하는 위치에 최대한 가까이 선언한다.
루프를 제어하는 변수는 흔히 루프 문 내부에 선언한다.

인스턴스 변수

인스턴스 변수는 클래스 맨 처음에 선언한다. 또한, 변수 간에 세로로 거리를 두지 않는다.

종속 함수

한 함수가 다른 함수를 호출한다면 두 함수는 세로로 가까이 배치한다. 또한 가능하다면 호출하는 함수를
호출되는 함수보다 먼저 배치한다. 그러면 프로그램이 자연스럽게 읽힌다.

개념적 유사성

친화도가 높을수록 코드를 가까이 배치한다. 친화도가 높은 요인은 여러 가지다. 한 함수가 다른 함수를 호출해 생기는 직접적인 종속성이 한 예이고, 변수와 그 변수를 사용하는 함수도 한 예이다.
아래 예시는 명명법이 같고 기본 기능이 유사하고 간단하다. 종속적인 관계가 없더라도 가까이 배치해야할 함수들이다.

public class Assert {
	static public void assertTrue(String message, boolean condition) {
    	if(!condition) 
        	fail(message);
    }
    
    static public void assertTrue(boolean condition) {
    	assertTrue(null, condition);
    }
    
    static public void assertFalse(String message, boolean condition) {
    	assertTrue(message, !condition);
    }
    
    static public void assertFalse(boolean condition) {
    	assertFalse(null, condition);
    }
}

세로 순서

  • 일반적으로 함수 호출 종속성은 아래 방향으로 유지한다.
  • 호출되는 함수를 호출하는 함수보다 나중에 배치하면, 소스 코드 모듈이 고차우너에서 저차원으로 자연스럽게 내려간다.

가로 형식 맞추기

  • 가급적 120자 이상을 넘지 않는 것이 좋다.

가로 공백과 밀집도

private void measureLine(String line) {
	lineCount++;
    int lineSize = line.length();
    totalChars += lineSize;
    lineWithHistogram.addLine(lineSize, lineCount);
    recordWidgetLine(lineSize);
}
  • 할당 연산자를 강조하려고 앞뒤에 공백을 줬다. 할당문은 왼쪽 요소와 오른쪽 요소가 분명히 나뉜다. 공백을 넣으면 두 가지 주요 요소가 확실히 나뉜다는 사실이 더욱 분명해진다.

  • 함수 이름과 이어지는 괄호 사이에는 공백을 넣지 않았다. 함수와 인수는 서로 밀접하기 때문이다. 공백을 넣으면 한 개념이 아닌 별개로 보인다.

  • 함수를 호출하는 인수는 쉼표를 강조해 인수가 별개라는 사실을 보여주기 위해 가로 공백을 준다.

  • 연산자 우선 순위를 강조하기 위해서도 공백을 사용한다.

public class Quadratic {
	public static double root1(double a, double b, double c) {
    	double determinant = determinant(a, b, c);
        return (-b + Math.sqrt(determinant)) / (2*a);
    }
    
    public static double root2(int a, int b, int c) {
    	double determinant = determinant(a, b, c);
        return (-b - Math.sqrt(determinant)) / (2*a);
    }
    private static double determinant(double a, double b, double c) {
    	return b*b - 4*a*c;
    }
}
  • 곱셈은 우선 순위가 가장 높아서, 승수 사이에는 공백이 없다. ex)2a, 4a*c
  • 덧셈과 뺄셈은 곱셈보다 우선 순위가 낮기 때문에, 항 사이에는 공백이 들어간다.

가로 정렬

  • 이전에는 변수를 선언할 때, 가로 공백을 맞춰서 나란히 정렬했으나, 이제는 선언문과 할당문을 별도로 정렬하지 않는다. 정렬하지 않으면 오히려 중대한 결함을 찾기가 쉽다.

들여쓰기

  • 코드는 윤곽도(outline) 계층과 비슷하며, 이러한 범위로 이루어진 계층을 표현하기 위해 우리는 코드를 들여쓴다.
  • 들여쓰는 정도는 계층에서 코드가 자리잡은 수준에 비례한다.
  • 클래스 정의처럼 파일 수준의 문장은 들여쓰지 않고, 클래스 내 메서드는 클래스보다 한 수준 들여쓴다.
  • 메소드 코드 내부는 메소드 선언보다 한 수준 들여쓰고, 블로코드는 블록을 포함하는 코드보다 한 수준 들여쓴다.
들여쓰기 무시하기
public class CommentWidget extends TextWidget 
{
	public static final String REGEXP = "^#[^\r\n]*(?:(?:\r\n)|\n|\r?";
    
    public CommentWidget(ParentWidget parent, String text) {super(parent, text);}
    public String render() throws Exception{ return "";}
}
public class CommentWidget extends TextWidget {
	public static final String REGEXP = "^#[^\r\n]*(?:(?:\r\n)|\n|\r?";
    
    public CommentWidget(ParentWidget parent, String text) {
    super(parent, text);
    }
    public String render() throws Exception{ 
    	return "";
    }
}

가짜 범위

  • 비어있는 while문이나 for문을 사용하지 않는 것이 좋지만, 만약 사용하게 되는 경우 새 행에 세미콜론을 들여써서 넣어준다.

팀 규칙

  • 프로그래머는 각자 선호하는 규칙이 있지만, 팀에 속한다면 자신이 선호해야 할 규칙은 바로 팀 규칙이다.
  • 팀은 한 가지 규칙에 합의해야 하고, 모든 팀원은 그 규칙을 따라야 한다.
  • 좋은 소프트웨어 시스템은 읽기 쉬운 문서로 이뤄진다는 사실을 기억해야 한다.
  • 스타일은 일관적이고 매끄러워야 하며, 한 형식이 다른 소스 파일에도 쓰이라는 신뢰가 필요하다.
public class CodeAnalyzer implements JavaFileAnalysis { 
    private int lineCount;
    private int maxLineWidth;
    private int widestLineNumber;
    private LineWidthHistogram lineWidthHistogram; 
    private int totalChars;

    public CodeAnalyzer() {
        lineWidthHistogram = new LineWidthHistogram();
    }

    public static List<File> findJavaFiles(File parentDirectory) { 
        List<File> files = new ArrayList<File>(); 
        findJavaFiles(parentDirectory, files);
        return files;
    }

    private static void findJavaFiles(File parentDirectory, List<File> files) {
        for (File file : parentDirectory.listFiles()) {
            if (file.getName().endsWith(".java")) 
                files.add(file);
            else if (file.isDirectory()) 
                findJavaFiles(file, files);
        } 
    }

    public void analyzeFile(File javaFile) throws Exception { 
        BufferedReader br = new BufferedReader(new FileReader(javaFile)); 
        String line;
        while ((line = br.readLine()) != null)
            measureLine(line); 
    }

    private void measureLine(String line) { 
        lineCount++;
        int lineSize = line.length();
        totalChars += lineSize; 
        lineWidthHistogram.addLine(lineSize, lineCount);
        recordWidestLine(lineSize);
    }

    private void recordWidestLine(int lineSize) { 
        if (lineSize > maxLineWidth) {
            maxLineWidth = lineSize;
            widestLineNumber = lineCount; 
        }
    }

    public int getLineCount() { 
        return lineCount;
    }

    public int getMaxLineWidth() { 
        return maxLineWidth;
    }

    public int getWidestLineNumber() { 
        return widestLineNumber;
    }

    public LineWidthHistogram getLineWidthHistogram() {
        return lineWidthHistogram;
    }

    public double getMeanLineWidth() { 
        return (double)totalChars/lineCount;
    }

    public int getMedianLineWidth() {
        Integer[] sortedWidths = getSortedWidths(); 
        int cumulativeLineCount = 0;
        for (int width : sortedWidths) {
            cumulativeLineCount += lineCountForWidth(width); 
            if (cumulativeLineCount > lineCount/2)
                return width;
        }
        throw new Error("Cannot get here"); 
    }

    private int lineCountForWidth(int width) {
        return lineWidthHistogram.getLinesforWidth(width).size();
    }

    private Integer[] getSortedWidths() {
        Set<Integer> widths = lineWidthHistogram.getWidths(); 
        Integer[] sortedWidths = (widths.toArray(new Integer[0])); 
        Arrays.sort(sortedWidths);
        return sortedWidths;
    } 
}

6. 객체와 자료 구조

변수를 비공개로 정의하는 이유가 있다. 남들이 변수에 의존하지 않게 만들고 싶어서다. 하지만 수많은 프로그래머가 조회함수와 설정함수(getter, setter)를 당연하게 공개해 비공개 변수를 외부에 노출할까?

자료 추상화

public class Point {
	public double x;
    public double y;
}
public interface Point {
	double getX();
    double getY();
    void setCartesian(double x, double y);
    double getR();
    double getTheta();
    void setPolar(double r, double theta);
}
  • 두 클래스 모두 2차원 점을 표현하지만, 위에 클래스는 구현을 외부로 노출하고 밑에 클래스는 구현을 완전히 숨긴다.
  • 변수를 private으로 설정하더라도, getter/setter를 제공한다면 구현을 외부로 노출하는 셈이다. 변수 사이에 함수라는 계층을 넣는다고 구현이 저절로 감춰지지 않는다.
  • 구현을 감추려면 추상화가 필요하다. > 추상 인터페이스를 제공해 사용자가 구현을 모른 채 자료의 핵심을 조작할 수 있어야 진정한 의미의 클래스다.
public interface Vehicle {
	double getFuelTankCapacityInGallons();
    double getGallonsOfGasoline();
}	
public interface Vehicle {
	double getPercentFuelRemaining();
}

상위 인터페이스는 자동차 연료 상태를 구체적인 숫자로 알려주지만, 하위 인터페이스는 자동차 연료 상태를 백분율이라는 추상적인 개념으로 알려준다.
상위 인터페이스는 변수값을 읽어 반환할 뿐이라는 사실이 거의 확실하지만, 하위 인터페이스는 정보가 어디서 오는지 전혀 드러나지 않는다.

자료/객체 비대칭

  • 객체는 추상화 뒤로 자료를 숨긴 채 자료를 다루는 함수만 공개한다.
  • 자료 구조는 자료를 그대로 공개하며 별다른 함수는 제공하지 않는다.
  • 객체와 자료구조의 정의는 본질적으로 상반된다.
절차적인 도형
public class Square {
	public Point topLeft;
	public double side;
}

public class Rectangle {
	public Point topLeft;
	public double height;
	public double width;
}

public class Circle {
	public Point center;
	public double radius;
	public double width;
}

public class Geometry {
	public final double PI = 3.141592653585793;

	public double area(Object shape) throws NoSuchShapeException {
		if(shape instanceOf Square) {
			Square s = (Square)shape;
			return s.side * s.side;
		}
	else if(shape instanceOf Rectangle) {
			Rectangle r = (Rectangle)shape;
			return r.height * r.width;
		}
	else if(shape instanceOf Circle) {
			Circle c = (Circle)shape;
			return PI * c.radius * c.radius
		}
	}
    throw new NoSuchShapeException();
}

Geometry 클래스는 세 가지 도형 클래스를 다루고, 각 도형 클래스는 간단한 자료 구조이다. 즉, 자료구조인 각 도형 클래스에서는 아무 메소드를 제공하지 않고, 도형이 동작하는 방식은 Geometry라는 객체에서 구현한다.

  • 클래스가 절차적이지만, 만약 둘레 길이를 구하는 함수를 추가하고 싶다면, 각 도형 클래스에는 아무런 영향을 미치지 않는다.
  • 반대로 새 도형을 추가하고 싶다면, Geometry 클래스에 속한 함수를 모두 고쳐야 한다.
다형적인 도형(객체지향적 도형)
public class Square implements Shape {
	public Point topLeft;
	public double side;

	public double area() {
		return side*side;
	}
}

public class Rectangle implements Shape {
	public Point topLeft;
	public double height;
	public double width;

	public double area() {
		return height*width;
	}
}

public class Circle implements Shape {
	public Point center;
	public double radius;
	public double width;

	public double area() {
		return PI*radius*radius;
	}
}

area 메소드는 다형 메소드로, Geometry 클래스는 필요하지 않다. 새 도형을 추가하고 싶더라도 기존 함수에 아무런 영향을 주지 않지만, 새 함수를 추가하고 싶다면 도형 클래스 전부를 고쳐야 한다.

  • 절차 지향과 객체 지향은 상호 보완적인 특질이 있어, 객체와 자료구조는 근본적으로 양분된다.

(자료 구조를 사용하는)절차적인 코드는 기존 자료 구조를 변경하지 않으면서 새 함수를 추가하기 쉽다. 반면, 객체 지향 코드는 기존 함수를 변경하지 않으면서 새 클래스를 추가하기 쉽다.

절차적인 코든느 새로운 자료 구조를 추가하기 어렵다. 그러려면 모든 함수를 고쳐야 한다. 객체 지향 코든느 새로운 함수를 추가하기 어렵다. 그러려면 모든 클래스를 고쳐야 한다.

객체 지향 코드에서 어려운 변경은 절차적인 코드에서 쉽고, 절차적인 코드에서 어려운 변경은 객체 지향 코드에서 쉽다.

디미터 법칙

  • 디미터 법칙은 잘 알려진 휴리스틱(발견법)으로 모듈은 자신이 조작하는 객체의 속사정을 몰라야 한다는 법칙이다. 객체는 자료를 숨기고 함수를 공개한다.
  • 즉, 객체는 getter로 내부 구조를 공개하면 안된다는 의미다.
클래스 C의 메서드 f는 다음과 같은 객체의 메서드만 호출해야 한다.

클래스 C
f가 생성한 객체
f 인수로 넘어온 객체
C 인스턴스 변수에 저장된 객체

쉽게 말해, 낯선 사람은 경계하고 친구랑만 놀라는 의미다.

final String outputDir = ctxt.getOptions().getScratchDir().getAbsolutePath();

기차 충돌

  • 여러 객차가 한 줄로 이어진 기차처럼 보여서, 위와 같은 코드를 기차 충돌이라고 부른다.
  • 일반적으로 조잡하다고 여겨지는 방식으로 피하는 것이 좋다.
Options opts = ctxt.getOption();
File scratchDir = opts.getScratchDir();
final String outputDir = scratchDir.getAbsolutePath();

기차 충돌 코드는 이처럼 수정하는 것이 좋지만, getter를 사용하는 바람에 디미터 법칙을 위반한 것인지 혼란을 일으킨다.

final String outputDir = ctxt.options.scratchDir.absolutPath;

위 코드처럼 구현한다면, 디미터 법칙을 거론할 필요가 없어진다.

잡종 구조

  • 이런 혼란으로 절반은 객체, 절반은 자료 구조인 잡종 구조가 나온다.
  • 잡종 구조는 중요한 기능을 수행하는 함수도 있고, 공개 변수나 공개 getter/setter도 있으며, getter/setter는 비공개 변수를 그대로 노출한다.
  • 이 때문에 다른 함수가 절차적인 프로그래밍의 자료 구조 접근 방식처럼 비공개 변수를 사용하고픈 유혹에 빠지기 쉽다.
  • 잡종 구조는 객체, 자료 구조의 단점만 모아놓는 구조로, 되도록 잡종 구조는 피하는 것이 좋다.

구조체 감추기

만약 ctxt, options, scratchDir이 진짜 객체라면, 기차 충돌 코드를 사용하면 안된다.

// 1번
ctxt.getAbsolutePathOfScratchDirectoryOption();

// 2번
ctxt.getScratchDirectoryOption().getAbsolutePath()

// 3번
BufferedOutputStream bos = ctxt.createScratchFileStream(classFileName);

첫 번째 방법은 ctxt 객체에 공개해야 하는 메서드가 너무 많아지고,
두 번째 방법은 getScratchDirectoryOption()이 객체가 아닌 자료 구조를 반환한다고 가정한다.
세 번째 방법은 ctxt 객체에 임시 파일을 생성하도록 하는 것으로, 객체에서 맡기기 적당한 임무로 보인다.
또한, ctxt 내부 구조를 드러내지 않으며, 모듈에서 자신이 몰라야 하는 여러 객체를 탐색할 필요가 없기에 디미터 법칙을 위반하지 않는다.

자료 전달 객체

  • 자료 구조체의 전형적인 형태는 공개 변수만 있고 함수가 없는 클래스로, 이런 자료 구조체를 자료 전달 객체(Data Transfer Object, DTO)라고 한다.
  • DTO는 데이터베이스와 통신하거나 소켓에서 받은 메세지의 구문을 분석할 때 유용하다.
  • DTO는 데이터베이스에 저장된 가공되지 않은 정보를 애플리케이션 코드에서 사용할 객체로 변환하는 일련의 단계에서 가장 처음으로 사용하는 구조체다.
  • 좀 더 일반적인 형태는 빈(bean) 구조다. 빈은 비공개 변수를 getter/setter로 조작한다. 일종의 사이비 캡슐화로 별다른 이익을 제공하진 않는다.
class Address {

	private final String postalCode;

	private final String city;

	private final String street;

	private final String streetNumber;

	private final String apartmentNumber;

	public Address(String postalCode, String city, String street, String streetNumber, String apartmentNumber) {
		this.postalCode = postalCode;
		this.city = city;
		this.street = street;
		this.streetNumber = streetNumber;
		this.apartmentNumber = apartmentNumber;
	}

	public String getPostalCode() {
		return postalCode;
	}

	public String getCity() {
		return city;
	}

	public String street() {
		return street;
	}

	public String streetNumber() {
		return streetNumber;
	}

	public String apartmentNumber() {
		return apartmentNumber;
	}
}

활성 레코드(active record)

  • 활성 레코드는 DTO의 특수한 형태이다. 공개 변수가 있거나 비공개 변수에 getter/setter 함수가 있는 자료 구조지만, 여기에 save, find와 같은 탐색 함수도 제공한다.
  • 활성 레코드는 테이터 베이스 테이블이나 다른 소스에서 자료를 직접 변환한 결과다.
  • 활성 레코드에 비지니스 규칙 메소드를 추가해 이런 자료 구조를 객체로 취급하는 개발자가 흔하지만, 이는 자료 구조도 객체도 아닌 잡종 구조가 나오기 때문에 바람직하지 않다.
  • 활성 레코드는 자료 구조로 취급하고, 비지니스 규칙을 담으면서 내부 자료를 숨기는 객체는 따로 생성한다.

결론

  • 객체는 동작을 공개하고 자료를 숨긴다. 그래서 동작을 변경하지 않으면서 새 객체 타입을 추가하기 쉬운 반면, 기존 객체에 새 동작을 추가하기는 어렵다.
  • 자료구조는 별다른 동작없이 자료를 노출한다. 그래서 기존 자료 구조에 새 동작을 추가하기는 쉬우나, 기존 함수에 새 자료 구조를 추가하기는 어렵다.
  • 시스템을 구현할 때, 새로운 자료 타입을 추가하는 유연성이 필요하다면, 객체가 적합하다.
  • 새로운 동작을 추가하는 유연성이 필요하면, 자료 구조와 절차적인 코드가 적합하다.
profile
개발자가 되기 위해서 공부중입니다 :ㅡ)

0개의 댓글