005. (Clean Code) 5. 형식 맞추기 - Formatting

5. 형식 맞추기 - Formatting

개발자라면 코드의 형식을 깔끔하게 맞추어야 한다.

코드 형식을 맞추기 위한 간단한 규칙을 정하고, 이를 자동으로 적용하는 도구를 활용하는 것이 좋다.

코드 형식은 의사소통의 일환으로 매우 중요하며, 개발자의 일차적인 의무라고 볼 수 있다.

5.1. 적절한 행 길이를 유지하라

소스 코드는 얼마나 길어야 적당할까?

세로길이부터 고민해보자.

객체지향 프로그래밍 언어의 경우 파일의 크기는 클래스의 크기와 밀접하다.

대표적인 언어인 자바 소스 파일은 크기가 어느정도일까?

위의 그림은 JUnit, FitNesse, testNG, Time and Money(tam), JDepend, Ant, Tomcat 프로젝트의 파일당 라인 길이를 보여준다.

FieNesse의 경우 평균 65줄이고, 전체 파일 중 3분의 1일 40줄에서 100줄 정도를 유지하고 있다.

JUnit, Time and Money 프로젝트도 대다수가 200줄 미만인 반면,

Tomcat과 Ant는 절반이 200줄을 넘어서고 수천줄에 달하는 파일도 존재한다.

그럼 위 그래프가 의미하는 것은 무엇일까?

대부분 200줄 정도인 파일로도 거대한 시스템을 구축하는 데 지장이 없다는 ㄷ쓰이다.

이는 반드시 지켜야하는 엄격한 규칙은 아니지만, 바람직한 규칙으로 삼는 것이 좋다.

5.2. 신문 기사처럼 작성하라

소스파일도 신문 기사와 비슷하게 작성해야한다.

이름은 간단하면서도 설명이 가능하게 짓는다.

이름만 보고도 올바른 모듈을 살펴보고 있는지 아닌지를 판단할 정도로 신경써서 지어야 한다.

소스 파일 첫 부분은 고차원의 개념과 알고리즘을 설명하고 아래로 내려갈수록 의도를 명세하고,

마지막에는 가장 저차원 함수와 세부 내역이 나와야한다.

5.3. 개념은 빈 행으로 분리하라

대부분의 코드는 왼쪽에서 오른쪽으로, 위에서 아래로 읽는다.

각 행은 수식이나 절을 나타내고, 일련의 행 묶음은 완결된 생각 하나를 표현한다.

이 생각 사잉네는 빈행을 분리해야 마땅하다.

아래 예제를 보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class BoldWidget(
parent: ParentWidget?,
text: String?
): ParentWidget(parent) {

companion object {
const val REGEXP = "'''.+?'''"
private val pattern = Pattern.compile(
"'''(.+?)'''",
Pattern.MULTILINE + Pattern.DOTALL
)
}

init {
val match = pattern.matcher(text)
match.find()
addChildWidgets(match.group(1))
}

@Throws(Exception::class)
fun render(): String {
val html = StringBuffer("<b>")
html.append(childHtml()).append("</b>")
return html.toString()
}
}

만약 위 코드에서 빈 행과 개행을 제거하면 어떻게 될까?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class BoldWidget(parent: ParentWidget?, text: String?): ParentWidget(parent) {
companion object {
const val REGEXP = "'''.+?'''"
private val pattern = Pattern.compile("'''(.+?)'''",Pattern.MULTILINE + Pattern.DOTALL)
}
init {
val match = pattern.matcher(text)
match.find()
addChildWidgets(match.group(1))
}
@Throws(Exception::class)
fun render(): String {
val html = StringBuffer("<b>")
html.append(childHtml()).append("</b>")
return html.toString()
}
}

이처럼 코드의 변경이 없더라도 코드의 가독성이 많이 상실되었음을 알 수 있다.

개행은 그만큼 중요하다.

5.4. 세로 밀집도

개행이 개념을 분리한다면 세로 밀집도는 연광성과 관련이 있다.

서로 밀접한 코드일 수록 세로로 가까운 곳에 위치해야 한다.

아래 예제를 보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class ReporterConfig {

/**
* The class name of the reporter listener
*/
private var m_className: String? = null

/**
* The properties of the reporter listener
*/
private val m_properties = arrayListOf<Property>()

fun addProperty(property: Property) {
m_properties.add(property)
}
}

위 코드는 의미없는 주석으로 두 변수를 떨어뜨려놓았다.

차라리 아래처럼 주석을 제거하는 것이 더욱 가독성이 좋다.

1
2
3
4
5
6
7
8
class ReporterConfig {
private var m_className: String? = null
private val m_properties = arrayListOf<Property>()

fun addProperty(property: Property) {
m_properties.add(property)
}
}

5.5. 수직 거리

함수 연관 관계와 동작 방식을 파악하려고 이 함수에서 저 함수를 오가는 것만큼 지치는 경험도 없다.

따라서 서로 밀접한 개념을 세로로 가까이 두어야 한다.

변수 선언

변수는 사용하는 위치에 최대한 가까이 선언한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@JvmStatic
fun readPreferences() {
val inputStream: InputStream? = null // HERE
try {
inputStream = FileInputStream(getPreferncesFile())
setPreferences(Properties(getPreferences()))
getPreferences().load(inputStream)
} catch (e: IOException) {
try {
if (inputStream != nul)
inputStream.close()
} catch (e: IOException) {

}
}
}

루프를 제어하는 경우 루프 문 내에 선언한다.

1
2
3
4
5
6
7
fun countTestCases(): Int {
var count = 0 // HERE
tests.forEach {
count += it.countTestCases()
}
return count
}

아주 긴 함수의 경우 블록의 상단이나 루프 직전에 변수를 선언하는 사례도 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// ...
m_suite.getTests().forEach { test ->
val tr: TestRunner = m_runnerFactory.newTestRunner(this, test) // HERE
tr.addListener(m_textReporter)
m_testRunners.add(tr)

invoker = tr.getInvoker()

tr.getBeforeSuiteMethods().forEach { method ->
beforeSuiteMethods.put(method.getMethod(), method)
}

tr.getAfterSuiteMethods().forEach { method ->
afterSuiteMethods.put(method.getMethod(), method)
}
}
// ...

인스턴스 변수

인스턴스 변수는 일반적인 변수와 달리 클래스의 제일 처음에 선언한다.

잘 설계한 클래스의 경우, 많은 메서드에서 해당 인스턴스 변수를 사용하기 때문이다.

참고 C++의 경우 모든 인스턴스 변수를 클래스의 마지막에 선언하는 가위 규칙(scissors rule) 을 적용한다.

아래 예제를 보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class TestSuite : Test {

companion object {
fun createTest(theClass: Class<out TestCase?>?, name: String?): Test {
// ...
}

@Throws(NoSuchMethodException::class)
fun getTestConstructor(theClass: Class<out TestCase?>?): Constructor<out TestCase?> {
// ...
}

fun warning(message: String?): Test {
// ...
}

private fun exceptionToString(t: Throwable): String {
// ...
}

private val fName: String? = null // HERE

private val fTests: Vector<Test> = Vector<Test>(10) // HERE
}
}

위처럼 코드를 살펴보다가 중간에 갑자기 인스턴스 변수가 나오면 당황할 수 밖에 없다.

코드의 이해도를 올릴 수 있는 알맞은 위치에 인스턴스 변수를 위치시키도록 하자.

종속 함수

한 함수가 다른 함수를 호출한다면 두 함수를 세로로 가까이 배치한다.

이러한 배치는 자연스럽게 위에서 아래로 코드를 읽을 수 있게 해준다.

아래가 적절한 예제 코드이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
public class WikiPageResponder implements SecureResponder {
protected WikiPage page;
protected PageData pageData;
protected String pageTitle;
protected Request request;
protected PageCrawler crawler;

public Response makeResponse(FitNesseContext context, Request request) throws Exception {
String pageName = getPageNameOrDefault(request, “FrontPage”);
loadPage(pageName, context);

if (page == null)
return notFoundResponse(context, request);
else
return makePageResponse(context);
}

private String getPageNameOrDefault(Request request, String defaultPageName) {
String pageName = request.getResource();
if (StringUtil.isBlank(pageName))
pageName = defaultPageName;

return pageName;
}

protected void loadPage(String resource, FitNesseContext context) throws Exception {
WikiPagePath path = PathParser.parse(resource);
crawler = context.root.getPageCrawler();
crawler.setDeadEndStrategy(new VirtualEnabledPageCrawler());
page = crawler.getPage(context.root, path);
if (page != null)
pageData = page.getData();
}

private Response notFoundResponse(FitNesseContext context, Request request) throws Exception {
return new NotFoundResponder().makeResponse(context, request);
}

private SimpleResponse makePageResponse(FitNesseContext context) throws Exception {
pageTitle = PathParser.render(crawler.getFullPath(page));
String html = makeHtml(context);

SimpleResponse response = new SimpleResponse();
response.setMaxAge(0);
response.setContent(html);
return response;
}
// ...
}

위 코드를 보고 getPageNameOrDefault() 함수 안에서 FrontPage 상수를 사용하는 방법도 있지만,

이 경우 상수가 저차원 함수에 묻히는 문제가 생긴다.

상수를 꼭 알아야하는 함수에서 실제로 사용하는 함수로 상수를 넘겨주는 것이 더욱 바람직하다.

개념적 유사성

어떤 코드는 서로 끌어당긴다.

이는 해당 코드의 개념적인 친화도가 높기 때문이다.

친화도가 높은 요인은 여러가지가 존재한다.

한 삼수가 다른 함수를 호출해서 생기는 직접적인 종속성이 그 예시이다.

그 외에는 비슷한 동작을 수행하는 함수이다.

아래 예제를 보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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);
}

// ...
}

위 함수들은 개념적인 친화도가 매우 높다.

작명 방식은 물론 기본 기능도 유사하고 간단하다.

서로가 서로를 호출하는 종속 관계가 없더라도 가까이 배치할만한 함수라고 볼 수 있다.

5.6. 가로 형식 맞추기

세로는 살펴보았으니 이제 가로를 볼 차례이다.

한 행은 가로로 얼마나 길어야할까?

이번에도 세로를 조사한 프로젝트를 기준으로 살펴보자.

20자에서 60자 사이의 행이 40%에 달하며, 10자 미만 30%이다.

80자 이후부터는 그 숫자가 금격하게 감소하고 있음을 알 수 있다.

예전에는 스크롤이 필요없을 정도로 길이를 짧게했으나, 최근엔 모니터들의 크기도 크기때문에 절대적인 규칙은 아니다.

일반적으로는 120자 정도의 행 길이를 규칙으로 삼는다.

가로 공백과 밀집도

가로는 개행이 아닌 공백을 사용해 개념의 밀접함과 느슨함을 표현한다.

아래 예제를 보자.

1
2
3
4
5
6
7
private void measureLine(String line) {
lineCount++;
int lineSize = line.length();
totalChars += lineSize;
lineWidthHistogram.addLine(lineSize, lineCount);
recordWidestLine(lineSize);
}

할당 연산자를 강조하기 위해 연산자의 앞 뒤로 공백을 주었다.

할당문은 공백을 통해 왼쪽 요소와 오른쪽 요소가 분명히 구분된다.

반면 함수 이름과 이어지는 괄호 사이에는 공백이 없다.

이는 함수와 인수가 서로 밀접하기 때문인다.

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

아래 예제를 보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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;
}
}

공백의 유무로 우선순위를 표현하여 가독성을 확보하였다.

다만 코드 형식을 맞춰주는 대부분의 도구는 연산자의 우선순위를 고려하지않으므로 균일한 공백을 부여한느 경우가 많다.

가로 정렬

특정 구조를 강조하기 위해 아래와 같이 가로 정렬을 하는 경우가 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class FitNesseExpediter implements ResponseSender {
private Socket socket;
private InputStream input;
private OutputStream output;
private Request request;
private Response response;
private FitNesseContext context;
protected long requestParsingTimeLimit;
private long requestProgress;
private long requestParsingDeadline;
private boolean hasError;

public FitNesseExpediter(Socket s,
FitNesseContext context) throws Exception
{
this.context = context;
socket = s;
input = s.getInputStream();
output = s.getOutputStream();
requestParsingTimeLimit = 10000;
}

최근인 위와 같은 정렬이 쓰이지않는다.

이는 엉뚱한 부분을 강조하고, 코드의 진짜 의도를 가릴 여지가 있기때문이다.

만약 정렬이 필요할 정도로 목록이 길다면, 문제는 목록의 길이이지, 정렬의 부족이 아니다.

만약 아래 코드처럼 선언부가 길다면 클래스를 쪼개는 것이 좋다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class FitNesseExpediter implements ResponseSender {
private Socket socket;
private InputStream input;
private OutputStream output;
private Request request;
private Response response;
private FitNesseContext context;
protected long requestParsingTimeLimit;
private long requestProgress;
private long requestParsingDeadline;
private boolean hasError;

public FitNesseExpediter(Socket s, FitNesseContext context) throws Exception {
this.context = context;
socket = s;
input = s.getInputStream();
output = s.getOutputStream();
requestParsingTimeLimit = 10000;
}
// ...
}

들여쓰기

소스 파일은 윤곽도와 계층이 비슷하다.

이때 들여쓰기를 사용해 범위로 이루어진 계층을 표현한다.

아래 들여쓰기가 적용된 코드 예쩨를 보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class FitNesseServer implements SocketServer {
private FitNesseContext context;

public FitNesseServer(FitNesseContext context) {
this.context = context;
}

public void serve(Socket s) {
serve(s, 10000);
}

public void serve(Socket s, long requestTimeout) {
try {
FitNesseExpediter sender = new FitNesseExpediter(s, context);
sender.setRequestParsingTimeLimit(requestTimeout);
sender.start();
} catch (Exception e) {
e.printStackTrace();
}
}
}

위 코드처럼, 들여쓰기한 파일은 그 구조가 한 눈에 들어온다.

개발자는 이 들여쓰기 체계에 크게 위존한다.

들여쓰기가 없다면 우리는 아래의 코드를 이해하고 작성해야할지도 모른다.

1
2
3
4
5
6
7
public class FitNesseServer implements SocketServer { private FitNesseContext
context; public FitNesseServer(FitNesseContext context) { this.context =
context; } public void serve(Socket s) { serve(s, 10000); } public void
serve(Socket s, long requestTimeout) { try { FitNesseExpediter sender = new
FitNesseExpediter(s, context);
sender.setRequestParsingTimeLimit(requestTimeout); sender.start(); }
catch(Exception e) { e.printStackTrace(); } } }

들여쓰기 무시하기

때로는 간단한 if문이나 while문 등에서는 의도적으로 들여쓰기를 제거하기도 한다.

이 경우에도 이왕이면 들여쓰기를 넣는 것이 좋다.

아래 예제를 보자.

1
2
3
4
5
6
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 ""; }
}

위 코드에 들여쓰기를 적용하면 아래와 같다.

1
2
3
4
5
6
7
8
9
10
11
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문을 만날 때도 있다.

최대한 피하는 것이 좋은 코드이지만, 불가피한 경우 들여쓰기를 적용하고 괄호로 감싼다.

아래 예제를 참조하자.

1
2
while (dis.read(buf, 0, readBufferSize) != -1)
;

5.7. 팀 규칙

개발자라면 각자 선호하는 규칙이 있다.

하지만 어떤 팀에 속한다면 최우선으로 지켜야하는 것은 개인이 아닌 팀의 규칙이다.

5.8. 밥 아저씨의 형식 규칙

마틴이 사용하는 규칙은 아주 간단하다.

코드 자체가 구현 표준 문서가 되도록 작성한다.

아래 예제 코드를 참고하자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
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;
}
}