AWK 책의 60줄짜리 Make
원문은 Ben Hoyt님이 에 게재했습니다. 이 블로그 구독하기
명저 The AWK Programming Language—아호, 와인버거, 커니핸이 쓴 그 유명한 AWK 책—7장 끝부분에는 Make 유틸리티의 단순화된 버전을 소개하는 몇 페이지가 있다. 단 한 페이지 분량의 AWK 코드로 작성된 버전이다.
본론에 들어가기 전에 AWK 책의 2판이 다음 달에 나온다는 소식을 전하고 싶다. 브라이언 커니핸이 개정을 훌륭하게 마무리했는데, 특히 탐색적 데이터 분석에 관한 새 장을 추가하고 이를 위해 AWK에 제대로 된 CSV 지원을 넣었다. 나는 2판 초고를 검토해 달라는 부탁을 받아 영광이었다.
AWK는 2023년에도 데이터를 탐색하는 데 여전히 빛을 발하며, 특히 새로운 --csv 옵션 덕분에 더욱 그렇다. CSV 모드는 가장 널리 설치된 AWK 버전인 Gawk(GNU AWK)에도 추가됐다. 내가 만든 GoAWK 구현은 이미 한동안 제대로 된 CSV 지원을 제공해 왔고, 다른 구현들에 맞춰 --csv 옵션도 추가했다.
책의 2판에도 Make 프로그램은 그대로 실려 있는데, 추가된 약간의 “공백과 중괄호” 덕분에 가독성이 높아졌다. 덕분에 50줄에서 62줄로 늘어났다.
이 글에서는 그 Make 프로그램을 소개한다. AWK가 원라이너에만 뛰어난 게 아니라 활용될 수 있는 스크립트 언어이기도 하다는 것을 보여주기 위해서다. 물론 그렇게 해야 하느냐는 별개의 문제지만.
이어서 같은 프로그램을 Python으로 작성하면 어떻게 보이는지 비교하고, 이런 작업에 AWK와 Python 중 언제 무엇을 선택하면 좋을지 간략히 논해보려 한다.
굳이 말할 필요도 없겠지만, 이 글은 순전히 학습을 위한 연습(나 자신과 독자를 위한)이며, 여러분의 프로젝트 빌드에 실제로 사용하라고 권하는 프로그램이 아니다!
원본 AWK 버전
책의 2판은 Make 프로그램을 다음과 같이 소개한다. (참고로 나는 여기서 “target”이라는 용어가 혼란스럽다고 생각한다. “source”나 “dependency”가 더 적절해 보인다.)
이 절에서는 이전 절의 깊이 우선 탐색 기법에 기반해, 유닉스
make명령을 모델로 한 기초적인 갱신 프로그램을 개발한다.업데이터를 사용하려면 시스템의 구성 요소가 무엇인지, 서로 어떻게 의존하는지, 그리고 그것들을 만드는 데 어떤 명령이 필요한지를 명시적으로 기술해야 한다. 이러한 의존성과 명령은
makefile이라 불리는 파일에 다음과 같은 형태의 규칙들의 연속으로 저장되어 있다고 가정하자.name: t1 t2 ... tn commands규칙의 첫 줄은 프로그램이나 파일 name이 타깃 t1, t2, …, tn에 의존한다는 의존 관계를 나타내며, 각 ti는 파일 이름이거나 다른 name이다. 각 의존 관계 뒤에는 name을 생성하는 데 필요한 명령을 나열한 한 줄 이상의 commands가 올 수 있다. 다음은
a.c와b.c라는 두 개의 C 파일과c.y라는 yacc 문법 파일을 가진 작은 프로그램을 위한makefile예시로, 전형적인 프로그램 개발 사례다.prog: a.o b.o c.o gcc a.o b.o c.o -ly -o prog a.o: prog.h a.c gcc -c prog.h a.c b.o: prog.h b.c gcc -c prog.h b.c c.o: c.c gcc -c c.c c.c: c.y yacc c.y mv y.tab.c c.c print: pr prog.h a.c b.c c.y첫 줄은
prog가 타깃 파일a.o,b.o,c.o에 의존함을 나타낸다. 둘째 줄은prog가 C 컴파일러 명령인gcc로a.o,b.o,c.o와 yacc 라이브러리y를 링크해 파일prog로 생성된다는 것을 말한다. 다음 규칙(세 번째 줄)은a.o가 타깃prog.h와a.c에 의존하며 이 타깃들을 컴파일해 만들어진다는 것을 나타내며,b.o도 마찬가지다. 파일c.o는c.c에 의존하고,c.c는 다시c.y에 의존하는데,c.y는 yacc 파서 생성기로 처리되어야 한다. 마지막으로make가 항상 연관된 동작을 수행하는데, 이 경우pr명령으로 모든 소스 파일을 출력하는 것이다.
makefile의 의존 관계는 그래프로 표현할 수 있는데, 왼쪽에 x가 있고 오른쪽 타깃 중 하나로 y가 있는 의존 규칙이 있을 때마다 노드 x에서 노드 y로 가는 간선이 존재하는 그래프다. 타깃이 없는 규칙의 경우 왼쪽 이름을 가진 후계자가 없는 노드가 생성된다. 위makefile에 대해서는 다음과 같은 의존 그래프가 만들어진다.prog print / | \ / | \ a.o b.o c.o / \ / \ \ / \/ \ \ a.c prog.h b.c c.c | c.y
물론 아주 단순화된 Make 버전이지만, 산출물, 의존성, 빌드 명령이라는 핵심 개념은 여전히 담고 있다.
동작 방식을 살펴보기 전에 AWK 책 2판에 실린 그대로의 전체 소스 코드를 아래에 실었다. 굵은 글씨를 클릭하면 펼쳐볼 수 있고, 코드에 대한 자세한 설명은 아래 “동작 원리”로 건너뛰어도 좋다.
AWK 책의 Make 프로그램 (전체 소스 코드).
BEGIN {
while (getline <"makefile" > 0) {
if ($0 ~ /^[A-Za-z]/) { # $1: $2 $3 ...
sub(/:/, "")
if (++names[nm = $1] > 1)
error(nm " is multiply defined")
for (i = 2; i <= NF; i++) # remember targets
slist[nm, ++scnt[nm]] = $i
} else if ($0 ~ /^\t/) { # remember cmd for
cmd[nm] = cmd[nm] $0 "\n" # current name
} else if (NF > 0) {
error("illegal line in makefile: " $0)
}
}
ages() # compute initial ages
if (ARGV[1] in names) {
if (update(ARGV[1]) == 0)
print ARGV[1] " is up to date"
} else {
error(ARGV[1] " is not in makefile")
}
}
function ages( f,n,t) {
for (t = 1; ("ls -t" | getline f) > 0; t++)
age[f] = t # all existing files get an age
close("ls -t")
for (n in names)
if (!(n in age)) # if n has not been created
age[n] = 9999 # make n really old
}
function update(n, changed,i,s) {
if (!(n in age))
error(n " does not exist")
if (!(n in names))
return 0
changed = 0
visited[n] = 1
for (i = 1; i <= scnt[n]; i++) {
if (visited[s = slist[n, i]] == 0)
update(s)
else if (visited[s] == 1)
error(s " and " n " are circularly defined")
if (age[s] <= age[n])
changed++
}
visited[n] = 2
if (changed || scnt[n] == 0) {
printf("%s", cmd[n])
system(cmd[n]) # execute cmd associated with n
ages() # recompute all ages
age[n] = 0 # make n very new
return 1
}
return 0
}
function error(s) { print "error: " s; exit }동작 원리
책에도 프로그램이 어떻게 동작하는지에 대한 설명이 있지만, 여기서는 내가 흥미롭다고 생각하는 부분에 초점을 맞춰 내 나름대로 설명해 보겠다.
BEGIN 블록은 이런 프로그램의 주 진입점이다. 표준 입력에서 암시적으로 줄을 읽는 대부분의 AWK 프로그램과 달리, 이 프로그램은 getline을 이용한 명시적 루프로 makefile을 읽는다.
BEGIN {
while (getline <"makefile" > 0) {
if ($0 ~ /^[A-Za-z]/) { # $1: $2 $3 ...
sub(/:/, "")
if (++names[nm = $1] > 1)
error(nm " is multiply defined")
for (i = 2; i <= NF; i++) # remember targets
slist[nm, ++scnt[nm]] = $i
} else if ($0 ~ /^\t/) { # remember cmd for
cmd[nm] = cmd[nm] $0 "\n" # current name
} else if (NF > 0) {
error("illegal line in makefile: " $0)
}
}
...
}getline <filename은 makefile을 (처음에) 열고 끝까지 한 줄씩 읽는 리다이렉션 구문이다. 줄($0)이 문자(/^[A-Za-z]/)로 시작하면 name: targets 규칙으로 간주한다.
sub(/:/, "") 호출은 현재 줄에서 콜론을 제거한다(두 인자 형태의 sub에서는 $0이 암시적이다).
그런 다음 names 배열을 확인해 이 규칙이 이미 정의되지 않았는지 확인한다. AWK 배열은 사실 연관 배열로, 키-값 맵을 일컫는 옛날식 용어다.
안쪽 for 루프는 각 타깃(또는 의존성)을 slist / scnt 자료구조에 추가한다. 이는 사실 리스트의 맵이지만, AWK가 중첩 컬렉션을 지원하지 않기 때문에 평탄화한 형태다. 루프 본문은 매우 간결하다.
for (i = 2; i <= NF; i++)
slist[nm, ++scnt[nm]] = $i이 루프는 각 의존성을 순회한다. 2번 필드부터 NF(줄의 필드 수)까지의 모든 필드 $i를 대상으로 한다.
각 의존성에 대해 현재 규칙(nm)의 소스 개수인 scnt[nm]을 증가시킨 뒤, 의존성 $i를 다중 키인 이름과 개수로 인덱싱해 slist에 저장한다. AWK는 각 키를 SUBSEP 구분자(기본값은 "\x1c")로 구분해 연결한 키를 만들어 다차원 또는 다중 키 배열을 흉내 낸다.
루프가 끝난 뒤 prog 예시에서는 slist와 scnt가 다음과 같은 모습이 된다.
slist
a.o,1: prog.h
a.o,2: a.c
b.o,1: prog.h
b.o,2: b.c
c.c,1: c.y
c.o,1: c.c
prog,1: a.o
prog,2: b.o
prog,3: c.o
scnt
a.o: 2
b.o: 2
c.c: 1
c.o: 1
prog: 3다시 위로 올라와서, 줄이 탭으로 시작하면 명령으로 보고 해당 이름의 명령 문자열에 덧붙인다.
cmd[nm] = cmd[nm] $0 "\n"그 외에 빈 줄이 아닌 경우(NF > 0)라면 makefile 오류로 처리한다.
마지막으로 while 루프에서 makefile을 다 읽은 뒤 ages()로 현재 디렉터리의 모든 파일 나이를 계산하고, update(ARGV[1])를 호출해 명령줄에 전달된 규칙을 갱신한다.
BEGIN {
...
ages() # compute initial ages
if (ARGV[1] in names) {
if (update(ARGV[1]) == 0)
print ARGV[1] " is up to date"
} else {
error(ARGV[1] " is not in makefile")
}
}ages 함수가 흥미로워지는 지점이다.
function ages( f,n,t) {
for (t = 1; ("ls -t" | getline f) > 0; t++)
age[f] = t # all existing files get an age
close("ls -t")
for (n in names)
if (!(n in age)) # if n has not been created
age[n] = 9999 # make n really old
}매개변수 이름 f, n, t 앞에 공백을 잔뜩 넣어 둔 것은 이들이 실제로는 지역 변수이며 인자로 받을 것을 기대한 것이 아님을 보여주기 위해서다. 이는 AWK의 특이한 점(커니핸 본인도 후회한다고 한다)인데, 지역 변수를 정의하는 유일한 방법이 함수 매개변수로 선언하는 것이며, 함수가 매개변수보다 적은 수의 인자로 호출되면 나머지는 기본값(숫자는 0, 문자열은 "")을 갖게 된다. 그래서 AWK 함수 정의에서는 이런 식의 공백을 자주 볼 수 있다.
다음 부분은 꽤 재치 있다. AWK는 셸과 유사한 | 구문을 지원해 프로그램 출력을 한 번에 한 getline씩 변수(이 경우 f)로 파이프할 수 있다. ls -t 명령은 현재 디렉터리의 파일들을 수정 시간 순으로, 가장 최신 것부터 정렬해 나열한다.
각 파일의 나이를 age[f]에 할당하는 루프가 끝난 뒤에는 close를 호출해 ls -t 파이프를 닫고 너무 많은 파일 핸들이 열려 있는 것을 방지한다.
마지막으로 규칙 이름들을 순회하면서 아직 생성되지 않은 파일의 age[n]에 임의의 큰 수를 할당해, 그 파일들이 아주 오래되어 갱신이 필요하다고 가정한다.
다음은 알고리즘의 핵심이 담긴 재귀 update 함수다.
function update(n, changed,i,s) {
if (!(n in age))
error(n " does not exist")
if (!(n in names))
return 0
changed = 0
visited[n] = 1
for (i = 1; i <= scnt[n]; i++) {
if (visited[s = slist[n, i]] == 0)
update(s)
else if (visited[s] == 1)
error(s " and " n " are circularly defined")
if (age[s] <= age[n])
changed++
}
visited[n] = 2
if (changed || scnt[n] == 0) {
printf("%s", cmd[n])
system(cmd[n]) # execute cmd associated with n
ages() # recompute all ages
age[n] = 0 # make n very new
return 1
}
return 0
}다시 한번 매개변수 목록을 보면 n은 기대되는 인자(갱신할 이름)이고, changed,i,s는 지역 변수다.
초기 검사를 마친 뒤 slist[n, 1]부터 slist[n, scnt[n]]까지 순회하며 의존성 목록을 돌린다. 아직 방문하지 않은 의존성이라면 update를 재귀 호출해 깊이 우선 탐색으로 의존성 그래프를 순회하며 해당 의존성을 먼저 갱신해야 하는지 확인한다.
if (visited[s = slist[n, i]] == 0)
update(s)재귀는 상단 근처의 if (!(n in names)) return 0 블록에서 종료된다. 갱신하려는 파일이 규칙 이름 목록에 없을 때—즉 의존 그래프의 리프 노드일 때—멈춘다.
if (age[s] <= age[n]) changed++ 블록은 현재 갱신 중인 파일보다 더 새로운 의존성이 있으면 changed 카운트를 증가시킨다.
순회 루프가 끝난 뒤 의존성이나 하위 의존성 중 하나라도 변경되었다면 system()으로 연관된 명령을 실행하고, 모든 파일의 나이를 다시 계산한 뒤 갱신이 이루어졌음을 호출자에게 알리기 위해 1을 반환한다.
scnt[n] == 0 조건은 예시의 print 규칙처럼 갱신하려는 규칙에 지정된 의존성이 없는 경우를 처리한다. 이 경우 항상 해당 명령을 다시 실행한다.
자, 이렇게 해서 AWK 한 페이지로 만든 최소한의 Make가 완성됐다!
Python 버전
흥미 삼아 책의 AWK Make를 Python으로 포팅해 아래에 실었다. 마찬가지로 굵은 글씨를 클릭하면 프로그램이 펼쳐진다.
내가 Python으로 포팅한 Make 프로그램 (전체 소스 코드).
import os, re, sys
slist = {} # slist[name] is list of rule's sources
cmd = {} # cmd[name] is shell command to run for rule
def main():
for line in open('makefile'):
if re.match('[A-Za-z]', line):
line = line.replace(':', '')
fields = line.split()
nm = fields[0]
if nm in slist:
error(f'{nm} is multiply defined')
slist[nm] = fields[1:] # remember targets
elif line.startswith('\t'): # remember cmd for current name
cmd[nm] = cmd.get(nm, '') + line
elif line.strip():
error(f'illegal line in makefile: {line}')
if sys.argv[1] in slist:
if not update(sys.argv[1]):
print(sys.argv[1], 'is up to date')
else:
error(f'{sys.argv[1]} is not in makefile')
def mtime(n):
try:
return os.stat(n).st_mtime
except FileNotFoundError:
return 0 # mark as old if it doesn't exist
def update(n, visited={}):
ntime = mtime(n)
if n not in slist and ntime == 0:
error(f'{n} does not exist')
if n not in slist:
return 0
changed = False
visited[n] = 1
for s in slist.get(n, []):
if s not in visited:
update(s)
elif visited[s] == 1:
error(f'{s} and {n} are circularly defined')
if mtime(s) > ntime:
changed = True
visited[n] = 2
if changed or len(slist.get(n, [])) == 0:
print(cmd[n], end='')
os.system(cmd[n]) # execute cmd associated with n
return 1
return 0
def error(msg):
print('error:', msg, file=sys.stderr)
sys.exit(1)
if __name__ == '__main__':
main()구조는 원본 AWK 버전과 매우 유사하지만, 이해를 조금 더 쉽게 만든다고 생각한 두 가지 단순화를 적용했다.
slist/scnt의 기묘함을 피하기 위한 더 단순한 자료구조 — Python에서는 그냥 리스트를 값으로 갖는 딕셔너리를 사용하면 된다. (diff 보기.)ls -t트릭 대신os.stat()으로 파일 수정 시간(mtime)을 직접 가져와 나이를 더 직관적으로 구하는 방식. 덕분에age맵과ages함수가 필요 없어진다. (diff 보기.)
의도한 건 아니지만, import 문과 if __name__ == '__main__' 상투구까지 포함해도 58줄이다. AWK 프로그램과 길이가 거의 같다.
Python 버전을 만들면서 AWK 버전도 비슷한 방식으로 단순화할 수 있다는 걸 깨달았다.
slist를 AWK 배열에 직접 저장하는 것이 개념적으로 더 단순하다. 키는 규칙 이름이고 값은 공백으로 구분된 의존성 목록 문자열(마치makefile에서처럼)인 키-값 맵이다. 필요할 때split을 이용해 의존성 문자열을 리스트(1부터 의존성 개수까지의 배열)로 바꾸면 된다. 이렇게 하면scnt와names가 아예 필요 없어진다. (diff 보기.)- Python 버전과 비슷하게
ls -t로 모든 파일을 나이 순으로 나열하는 대신stat을 호출해 mtime을 직접 가져올 수도 있다. 나는 이를 위해stat --format %y를 사용했다. 이는 GNU 확장으로 알고 있어ls -t만큼 이식성이 좋지는 않지만, 더 단순하고age배열을 다시 계산할 필요가 없어진다. (diff 보기.)
업데이트: Volodymyr Gubarkov가 지적했듯이 stat 버전은 “외부 프로세스 호출을 여러 번 추가한다”는 말이 맞다. 더 직접적이긴 하지만 속도가 현저히 느려진다.
참고로 수정된 버전은 원본보다 네 줄이 짧다. 더 단순해진 slist가 더 명확하다고 생각하고, mtime을 가져오는 더 직접적인 방식도 마음에 들지만, stat --format의 이식성 부족이 단점이라는 점은 인정한다(macOS의 stat은 꽤 다르다).
결론
AWK Make 프로그램은 AWK라는 언어가 중간 규모 스크립트에서도 얼마나 유용한지를 보여주는 깔끔하고 작은 코드다.
하지만 이런 종류의 작업에는 Python이 확실히 더 좋은 언어다. 훨씬 풍부한 자료형을 갖추고 있고, os.stat 같은 더 나은 도구를 제공하며, 기묘한 문법 없이 지역 변수를 쓸 수 있다.
나는 AWK가 놀랍다고 생각하지만, AWK는 가장 잘하는 영역에 머무는 게 좋다고 본다. 탐색적 데이터 분석과 원라이너 데이터 추출 스크립트 같은 곳이다.
한동안 네이티브 CSV 지원을 제공해 온 GoAWK의 저자로서, 커니핸의 “진정한 AWK(one true AWK)”와 Gawk 모두 --csv 옵션 형태로 제대로 된 CSV 지원을 갖추게 된 것이 특히 반갑다. 커니핸의 AWK 업데이트는 곧 병합될 예정이며, Gawk도 버전 5.3.0에 이 기능을 포함할 예정이다.
AWK 책의 Make 프로그램과 내가 만든 Python 버전의 전체 소스는 물론, AWK 책의 예제를 기반으로 한 실행 가능한 예제 프로젝트가 담긴 내 awkmake 저장소를 GitHub에서 볼 수도 있다.
글을 무작위로 읽기
댓글
로그인하고 댓글 남기기