1 마지막 마무리

Notebook 파일에서 데이터를 추출하는데 사용한 함수를 마지막으로 한번 더 살펴보자.

def get_date(record):
    '''Return (Y, M, D) as strings, or None.'''

    # 2010-01-01
    m = re.search('([0-9]{4})-([0-9]{2})-([0-9]{2})',
                  record)
    if m:
        return m.group(1), m.group(2), m.group(3)

    # Jan 1, 2010 (comma optional, day may be 1 or 2 digits)
    m = re.search('/([A-Z][a-z]+) ([0-9]{1,2}),? ([0-9]{4})/',
                  record)
    if m:
        return m.group(3), m.group(1), m.group(2)

    return None

패턴을 더 선언적으로 만듦으로써 함수에 신규 패턴 추가를 더 쉽게 만들 수 있다. 사용할 기법은 정규표현식을 반환할 그룹집단 ID와 결합하는 것이다:

def get_fields(record):
    '''Return (Y, M, D, site, reading) or None.'''

    patterns = [
        ['(.+)\t([0-9]{4})-([0-9]{2})-([0-9]{2})\t(.+)',      2, 3, 4, 1, 5],
        ['(.+)/([A-Z][a-z]+) ([0-9]{1,2}),? ([0-9]{4})/(.+)', 4, 2, 3, 1, 5]
    ]
    for pattern, year, month, day, site, reading in patterns:
        m = re.search(pattern, record)
        if m:
            return m.group(year), m.group(month), m.group(day), m.group(site), m.group(reading)

    return None

리스트 patterns 에 각 항목은 두 부분을 갖추고 있다: 정규표현식과 패턴이 매칭되면 년, 월, 일, 장소명, 측정값을 담게되는 그룹집단 인덱스. 루프는 patterns에 정규표현식을 하나씩 돌린다. 패턴이 매칭되게 되면, 인덱스에 맞춰 순서를 바꿔서 매칭된 그룹집단을 반환한다. 그래서 데이터가 항상 동일한 순서로 뽑아내게 된다. Notebook #3에 나온 형식을 처리하는데, 한줄을 표에 추가만 한다:

['([A-Z][a-z]+) ([0-9]{1,2}) ([0-9]{4}) \\((.+)\\) (.+)', 3, 1, 2, 4, 5]

표를 사용하는 것이 지금까지 사용해온 “매칭, 추출, 반환(match, extract, return)” 스타일에 대해서 그다지 향상이 있어 보이지는 않는다. 하지만, 표-기반 접근법이 한가지 장점은 있다: 독자에게 모든 패턴이 동일한 방식으로 처리된다는 신호를 전달한다. “매칭, 추출, 반환” 함수 브랜치를 프로그래머가 변경하기는 너무서 약간 다른 방식으로 처리하는 가능성은 항상 열려있다. 이런 점이 독자가 어떤 일이 벌어지고 있는지 이해하기 어렵고, 동일하게 다음 작업을 하는 다음번 프로그래머가 코드를 디버그하거나 확장하기 어렵게 만든다. 코드가 더 명시적일수록, 독자가 이해할 것이 정말 하나만 있다는 확신을 더 갖게 된다.

2 더 많은 도구

정규표현식에 대한 탐험을 마무리하는데, 중간정도 복잡한 문제를 작업하면서 정규표현식 라이브러리 도구를 몇개더 소개한다. 출발점은 \(LaTeX\) 으로 작성된 논문 수천개 아카이브 저장소다. LaTeX은 텍스트-기반 문서 서식 프로그램이다. LaTeX 문서는 라벨을 사용해서 공유 참고문헌에 등재된 서지항목을 참조한다. 얼마나 많은 인용이 함께 되고 있는지 파악하는 것이 작업내용이다. 즉, 얼마나 자주 논문 X가 논문 Y와 동일 논문에 인용되는가? 이 질문에 답하는데, 각 논문에서 인용 라벨 집합을 추출할 필요가 있다.

입력 데이터를 좀더 자세히 살펴보자:

Granger's work on graphs \cite{dd-gr2007,gr2009},
particularly ones obeying Snape's Inequality
\cite{ snape87 } (but see \cite{quirrell89}),
has opened up new lines of research.  However,
studies at Unseen University \cite{stibbons2002,
stibbons2008} highlight several dangers.

LaTeX에서 인용을 괄호 내부에 상호참조 라벨, \cite{…} 형식으로 작성한다. 독립 인용은 라벨을 두개 혹은 그이상은 콤마로 구분해서 포함시킨다. 라벨 혹은 줄바꿈 전후에 공백이 있을 수 있다. 이런 경우 인용은 두줄로 쪼개지고, 줄마다 다수 인용이 위치할 수 있다.

첫번째 아이디어는 그룹집단을 사용해서 ‘cite’ 단어 다음에 오는 괄호 내부 모든 것을 잡아내는 것이다:

m = re.search('cite{(.+)}', 'a \\cite{X} b')
print m.groups()
('X',)

간단한 경우에 동작하는 것처럼 보이지만, 한줄에 인용이 다수 존재하면 어떨까?

m = re.search('cite{(.+)}', 'a \\cite{X} b \\cite{Y} c')
print m.groups()
('X} b \\cite{Y',)

인용 사이 텍스트를 잡아내는 것처럼 보인다. 이유는 정규표현식 매칭이 탐욕적(greedy)이기 때문이다: 정규표현식은 가능한 많은 텍스트를 매칭하고 .+ 내부 ‘.’ 은 첫번째 여는 괄호부터 마지막 괄호까지 모든 텍스트를 매칭하는데, 사이에 오는 인용과 괄호도 포함된다.

문제에 대한 진단이 해결책도 제시해준다: 정규표현식이 마무리 괄호를 제외한 모든 것을 매칭하게 하자. 이를 작성하는 것은 쉽다: 꺾쇠괄호 내부집합에 첫번째 문자가 곡절악센트 \^ 이면, 집합을 부정한다. 즉, 집합에 있는 문자를 제외한 모든 것을 매칭한다. 따라서, 표현식 [^}] 은 마무리 괄호를 제외한 모든 문자를 매칭한다. 작성한 코드를 실행해보자:

m = re.search('cite{([^}]+)}', 'a \\cite{X} b')
print m.groups()
('X,)

작성한 패턴은 독립 인용에 대해 동작한다: 작업한 것은 ’.’을 부정집합으로 바꾼 것이 전부다. 한줄에 인용 다수 있으면 어떨까?

m = re.search('cite{([^}]+)}', 'a \\cite{X} b \\cite{Y} c')
print m.groups()
('X,)

원치 않는 텍스트를 잡아내지는 않는다. 하지만, 첫번째 인용만 잡아내고 있다. 첫번째 뿐만 아니라 매칭되는 모든 것을 추출할 필요가 있다.

정규표현식 라이브러리에는 정확하게 이런 작업을 수행하는 함수가 있다: re.search 대신에 re.findall 함수를 사용하면, 패턴을 매칭하는 모든 문자열 목록을 반환한다. 기억할 점은 작성하고 있는 프로그램이 무엇이든 간에, 누군가 이전에 아마도 동일한 문제에 봉착했을 것이고, 아마도 도움을 줄 수 있는 라이브러리가 있을 것이다. 어떤 문헌이 있는지 아는 것이 과학자에게 중요하듯이, 라이브러리에 어떤 것이 있는지 아는 것이 프로그래머에게도 중요하다. 좋지 못한 소식은, 일반적으로 문서 혹은 라이브러리에서 그런 것을 찾아내는 것이 쉽지는 않다. 만약 어떤 검색어로 검색할지 충분히 문제에 관해 알지 못하다면 그렇다.

findall 함수로 시도해 보자:

print re.findall('cite{([^}]+)}', 'a \\cite{X} b \\cite{Y} c')
['X', 'Y']

올바른 출력결과를 산출하는 것으로 보인다 - 문자 7개 변경치고는 나쁘지 않다. 인용에 공백이 있다면 어떨까?

print re.search('cite{([^}]+)}', 'a \\cite{ X} b \\cite{Y } c').groups()
[' X', 'Y ']

좋은 소식은 프로그램이 정상적으로 동작한다는데 있다. 나쁜 소식은 공백도 findall 함수에 의해 함께 저장된다는 점이다. 이점은 분명히 원하는 바는 아니다. string.strip 함수를 사용한 후에 깔끔하게 정리할 수도 있지만, 대신에 패턴을 변경시켜 보자:

print re.findall('cite{\\s*([^}]+)\\s*}', 'a \\cite{ X} b \\cite{Y } c')
['X', 'Y ']

기억을 상기하면, '\s' 은 화이트스페이스 문자 집합에 대한 축약이다. 따라서, '\s*' 을 사용하게되면, 여는 괄호 다음에 혹은 닫는 괄호 앞에 바로 위치하는 공백을 0회 혹은 그이상 매칭한다. (그리고, 파이썬 문자열에 역슬래쉬로 '\\s' 작성해야만 된다). 하지만, ‘Y’ 다음에 공백은 여전히 매칭된 텍스트에 반환되어 나오고 있다.

다시 한번, 문제는 정규표현식이 탐욕적이라는 점에 있다: ‘Y’ 다음에 공백은 닫는 괄호가 아니라서, 부정된 문자집합에 매칭되어, 반환되는 문자열에 포함된다. 꼬리쪽 공백을 매칭하기로 되어있던 '\s' 이 문자 0개에 대해 매칭되게 된다. 원하는 바는 아니지만, 적법하다.

'\b'을 사용해서 단어에서 단어가 아닌 문자로 넘어가는 것을 정리하는 매칭을 만들자:

print re.findall('cite{\\s*\\b([^}]+)\\b\\s*}', 'a \\cite{ X} b \\cite{Y } c')
['X', 'Y']

잘 동작한다! 마지막 예제를 검사하자: 파워포인트에 여전히 ‘X’ 앞에 공백이 있다. 첫번째 원하지 않는 공백 앞에 그리고 마지막에 '\b' 을 변경사항으로 넣는다. 괄호 라벨 주변 괄호도 단어가 아닌 문자라서, 임의 여는 혹은 꼬리에 붙는 공백이 없을 때도 패턴이 매칭된다.

마지막 장애물은 단일 괄호짝 내부에 있는 라벨 다수를 처리하는 것이다. 지금까지 만든 패턴은 라벨이 두개 혹은 그 이상 되는 경우 확장되지 않는다. 단지 콤마 다음에 공백을 처리할 뿐이다. 하지만, 라벨 모두를 단일 텍스트 덩어리로 반환은 한다.

print re.findall('cite{\\s*\\b([^}]+)\\b\\s*}', '\\cite{X,Y} ')
['X,Y']
print re.findall('cite{\\s*\\b([^}]+)\\b\\s*}', '\\cite{X, Y, Z} ')
['X, Y, Z']

실제로 콤마에서 모든 것을 끊게 되는 패턴을 작성할 수도 있지만, 정규표현식 라이브러리의 매우 고급 기능을 필요로 한다. 대신에, 라벨 다수를 구분하는데 또다른 기본 함수(re.split)를 사용한다. re.split 함수는 string.split 함수와 동일한 작업을 수행한다. 하지만, 사촌과 달리 패턴이 매칭하는 모든 것을 구분한다.

동작방법을 시연하는 최선의 방법은 최초 생성하려했던 함수에 작성하는 것이다. 테스트 데이터를 포함하는 뼈대에서 시작해보자. 뼈대는 함수로 아무 작업도 수행하지 않고(하지만 실패하지도 않음), 함수를 호출하는 코드가 몇줄 있고 결과를 화면에 출력한다:

def get_citations(text):
    '''Return the set of all citation tags found in a block of text.'''
    return set() # to be done

if __name__ == '__main__':
    test = '''\
Granger's work on graphs \cite{dd-gr2007,gr2009},
particularly ones obeying Snape's Inequality
\cite{ snape87 } (but see \cite{quirrell89}),
has opened up new lines of research.  However,
studies at Unseen University \cite{stibbons2002,
stibbons2008} highlight several dangers.'''

    print get_citations(test)
set([])

이제 함수를 작성해보자. 가독성 증진을 위해서, 상단에 패턴을 두고 기억이 잘되는 명칭을 부여한다. 함수 내부에, 첫번째 패턴과 매칭되는 인용 모두를 뽑아내고 나서, 선택옵션 공백을 갖는 콤마를 갖는 앞뒤 모든 결과 각각을 쪼갠다. 결과 모두를 집합으로 우겨넣어 결과를 반환한다. 매칭되는 것이 전혀 발견되지 않으면, 집합은 공집합이 된다.

p_cite = 'cite{\\s*\\b([^}]+)\\b\\s*}'
p_split = '\\s*,\\s*'

def get_citations(text):
    '''Return the set of all citation tags found in a block of text.'''

    result = set()
    match = re.findall(p_cite, text)
    if match:
        for citation in match:
            cites = re.split(p_split, citation)
            for c in cites:
                result.add(c)

    return result

함수를 좀더 효율적으로 만드는데 정규표현식 라이브러리에서 몇가지 기법을 사용할 수도 있다. 정규표현식을 유한상태기계로 바꿔서 반복적으로 사용하는 대신에, 정규표현식을 컴파일하고 나서 결과로 도출된 객체를 저장한다:

p_cite = re.compile('cite{\\s*\\b([^}]+)\\b\\s*}')
p_split = re.compile('\\s*,\\s*')

def get_citations(text):
    '''Return the set of all citation tags found in a block of text.'''

    result = set()
    match = p_cite.findall(text)
    if match:
        for citations in match:
            label_list = p_split.split(citations)
            for label in label_list:
                result.add(label)

    return result

상기 객체는 search , findall 같이 라이브러리에서 사용되고 있는 동일한 함수명과 같은 메쏘드를 갖는다. 하지만 동일한 패턴을 반복해서 사용한다면, 컴파일 한번 하고 컴파일된 객체를 재사용하는 것이 훨씬 더 빠르다.

잠시 살펴봤듯이, 변경에 필요한 것은 매우 적다: 텍스트 형식으로 표현식을 저장하는 대신에, 컴파일하고 나서, 정규표현식 라이브러리에서 최상단 함수를 호출하는 대신에, 저장된 객체에 속한 메쏘드를 호출한다. 실행결과는 10 여줄 코드로 추출된, 모든 인용집합이다.

import re import

CITE = 'cite{\\s*\\b([^}]+)\\b\\s*}'
SPLIT = '\\s*,\\s*'

def get_citations(text):
  '''Return the set of all citation tags found in a block of text.'''
  result = set()
  match = CITE.findall(text)
  if match: if
    for citations in for match:
      label_list = SPLIT.split(citations)
    for label in for label_list:
      result.add(label)
  return result

if __name__ == '__main__':
    test = '''\
Granger's work on graphs \cite{dd-gr2007,gr2009},
particularly ones obeying Snape's Inequality
\cite{ snape87 } (but see \cite{quirrell89}),
has opened up new lines of research.  However,
studies at Unseen University \cite{stibbons2002,
stibbons2008} highlight several dangers.'''

    print get_citations(test)
set(['gr2009', 'stibbons2002', 'dd-gr2007', 'stibbons2008',
     'snape87', 'quirrell89'])

마지막으로, 정규표현식을 컴파일하게 되면, 주석을 추가하는데 상세 모드(verbose mode)를 사용함으로써 더 이해하기 쉽게 만들 수 있다. 상세모드는 정규표현식의 주석과 화이트스페이스를 무시하도록 파이썬에 전달한다. 이를 통해 다음과 같은 패턴을 작성하게 된다:

p_cite = '''
    cite{          # start with literal 'cite{'
    \\s*           # then some optional spaces
    \\b            # up to a start-of-word boundary
    ([^}]+)        # then anything that isn't a closing '}'
    \\b            # then an end-of-word boundary
    \\s*           # and some more optional spaces
    }              # and the closing '}'
'''
matcher = re.compile(p_cite, re.VERBOSE)

위와 같이 패턴을 문서화하게 되면 정규표현식 패턴을 고치기 쉽고, 확장하기 쉽게 만들게 된다.