DeepintoPharo:Chapter 06
- 제 6 장 Pharo에서의 정규 표현식
Pharo에서의 정규 표현식
- Oscar Nierstrasz 참여(oscar.nierstrasz@acm.org)
정규 표현식은 Perl, Python, Ruby와 같은 많은 스크립팅 언어에서 널리 사용된다. 정규 표현식은 특정 패턴에 일치하는 문자열을 식별하고, 입력이 예상된 포맷에 따르는지 검사하며, 문자열을 새로운 포맷으로 재작성하는 데에 유용하다. Pharo 또한 Vassili Bykov가 기여한 Regex 패키지 때문에 정규 표현식을 지원한다. Regex는 기본적으로 Pharo에 설치된다.
정규 표현식[1]은 문자열 집합에 일치하는 템플릿이다. 예를 들어, 정규 표현식 'h.*o'는 문자열 'ho, 'hiho', 'hello'에 매치하겠지만 'hi' 또는 'yo'에는 매치하지 않을 것이다. Pharo에서 아래와 같이 볼 수 있다:
'ho' matchesRegex: 'h.*o' → true
'hiho' matchesRegex: 'h.*o' → true
'hello' matchesRegex: 'h.*o' → true
'hi' matchesRegex: 'h.*o' → false
'yo' matchesRegex: 'h.*o' → false
본 장은 몇 가지 클래스를 발전시켜 웹 사이트의 매우 단순한 사이트 맵을 생성하는 작은 지침용 예제로 시작하겠다. 정규 표현식을 이용하여 (I) HTML 파일을 식별하고, (ii) 파일의 전체 경로명에서 경로명을 빼내고, (iii) 사이트 맵에 대한 각 웹 페이지를 추출하고, (iv) 웹 사이트의 루트 디렉터리로부터 그것이 포함하는 HTML 파일에 대한 상대 경로를 생성할 것이다. 지침용 예제를 완료한 후에는 대체적으로 Regex 패키지에 Vassili Bykov가 제공한 문서를 바탕으로 패키지[2]에 대한 완전한 설명을 제공하겠다.
지침용 예제 - 사이트 맵 생성하기
우리가 할 일은 하드 드라이브에 국부적(locally)으로 보관된 웹 사이트에 대한 사이트 맵을 생성시킬 간단한 애플리케이션을 작성하는 일이다. 사이트 맵은 링크의 텍스트로서 문서의 제목을 이용해 웹 사이트 내 각 HTML 파일에 대한 링크를 포함할 것이다. 또한 링크는 웹 사이트의 디렉터리 구조를 반영하도록 할 것이다.
웹 디렉터리 접근하기
자신의 기계에 웹 사이트가 없다면 테스트베드(test bed) 역할을 하도록 몇 개의 HTML 파일을 로컬 디렉터리로 복사하라.
두 개의 클래스, WebDir와 WebPage를 개발하여 디렉터리와 웹 페이지를 나타내도록 할 것이다. 우리의 웹 사이트를 포함하는 루트 디렉터리를 가리킬 WebDir의 인스턴스를 생성하는 것이 의도이다. 이곳으로 makeToc 메시지를 전송하면 그 내부의 파일과 디렉터리를 살펴보고 사이트 맵을 형성할 것이다. 이후 웹 사이트 내 모든 페이지의 링크를 포함하는 toc.html이라는 새 파일이 생성될 것이다.
이 때 유의해야 할 점이 하나 있다: 각 WebDir 과 WebPage 는 웹 사이트의 루트에 대한 경로를 기억하여 루트에 상대적인 링크를 적절하게 생성할 수 있도록 해야 한다.
webDir와 homePath 인스턴스 변수가 있는 WebDir 클래스를 정의하고, 적절한 초기화(initialization) 메서드를 정의한다. 또한 아래와 같이 클래스 측 메서드를 정의하여 사용자에게 자신의 컴퓨터 상에서 웹 사이트의 위치를 입력하라고 표시될(prompt)것이다.
WebDir>>setDir: dir home: path
webDir := dir.
homePath := path
WebDir class>>onDir: dir
^ self new setDir: dir home: dir pathName
WebDir class>>selectHome
^ self onDir: FileList modalFolderSelector
마지막 메서드는 열어야 할 디렉터리를 선택하기 위해 브라우저를 연다. 이제 WebDir selectHome의 결과를 검사한다면 자신의 웹 페이지를 포함하는 디렉터리를 입력하도록 표시될 것이고, 당신은 webDir과 homePath가 당신의 웹 사이트와 해당 디렉터리의 전체 경로명을 포함하는 디렉터리로 적절하게 초기화되는지 확인할 수 있을 것이다.
프로그램에 따라 WebDir를 인스턴스화할 수 있다면 좋을 것이므로 다른 생성 메서드를 추가해보자.
아래 메서드를 추가하여 WebDir onPath: 'path to your web site' 의 결과를 검사함으로써 시도해보라.
WebDir class>>onPath: homePath
^ self onPath: homePath home: homePath
WebDir class>>onPath: path home: homePath
^ self new setDir: (path asFileReference) home: homePath
HTML 파일의 패턴 매칭
지금까진 매우 좋다. 이제 regex를 이용해 이 웹 사이트가 어떤 HTML 파일을 포함하는지 알아내도록 하겠다.
AbstractFileReference 클래스를 살펴보면, fileNames 메서드가 디렉터리 내 모든 파일을 열거할 것임을 알 수 있다. 우리는 파일 확장자가 .html 인 파일만 선택하길 원한다. 우리에게 필요한 정규 표현식은 '.*\.html'이다. 첫 번째 점은 어떤 문자든 매치시킬 것이다.
'x' matchesRegex: '.' → true
' ' matchesRegex: '.' → true
Character cr asString matchesRegex: '.' → true
*(이것을 개발한 Stephen Kleene의 이름을 본따 "클리니 스타(Kleene star)"라고 알려짐)은 정규 표현식 연산자로서, (0을 포함해) 앞에 몇 개의 정규 표현식이든 나타날 수 있다는 의미다.
''matchesRegex: 'x*' → true
'x' matchesRegex: 'x*' → true
'xx' matchesRegex: 'x*' → true
'y' matchesRegex: 'x*' → false
점은 regex에서 특수 문자기 때문에 말 그대로 점을 일치시키기 위해서는 escape 시켜야 한다.
'.' matchesRegex: '.' → true
'x' matchesRegex: '.' → true
'.' matchesRegex: '\.' → true
'x' matchesRegex: '\.' → false
이제 HTML 파일이 예상한 대로 작동하는지 알아보기 위해 정규 표현식을 확인해보자.
'index.html' matchesRegex: '.*\.html' → true
'foo.html' matchesRegex: '.*\.html' → true
'style.css' matchesRegex: '.*\.html' → false
'index.htm' matchesRegex: '.*\.html' → false
괜찮아 보인다. 그렇다면 애플리케이션에서 시도해보자.
아래 메서드를 WebDir 에 추가하고 자신의 테스트용 웹 사이트에서 시도해보라.
WebDir>>htmlFiles
^webDir fileNames select: [ :each | each matchesRegex: '.*\.html' ]
htmlFiles 를 WebDir 인스턴스로 보내고 print it하면, 아래와 같은 결과를 볼 수 있을 것이다.
(WebDir onPath: '...') htmlFiles → #('index.html' ...)
정규 표현식의 캐시 저장
이제 matchesRegex: 를 살펴보면 이것을 전송할 때마다 RxParser의 새로운(fresh) 인스턴스를 생성하는 것은 String의 확장 메서드라는 것을 발견할 것이다. Ad hoc 쿼리에는 괜찮지만 같은 정규 표현식을 웹 사이트 내 모든 파일로 적용한다면 RxParser의 인스턴스 하나만 생성하여 재사용하는 편이 현명하겠다. 이 방법을 시도해보자.
새로운 인스턴스 변수 htmlRegex를 WebDir로 추가하고, 우리의 정규 표현식 문자열에 asRegex를 전송하여 초기화하라. 아래와 같이 매번 같은 정규 표현식을 사용하도록 WebDir>>htmlFiles를 수정하라.
WebDir>>initialize
htmlRegex := '.*\.html' asRegex
WebDir>>htmlFiles
^webDir fileNames select: [ :each | htmlRegex matches: each ]
이제 동일한 regex를 여러 번 재사용한다는 점을 제외하면 HTML 파일의 열거는 이전처럼 작동할 것이다.
웹 페이지 접근하기
각 웹 페이지의 세부 내용을 접근하는 일은 구분된 클래스의 책임이므로, 이를 정의하여 WebDir 클래스가 인스턴스를 생성하도록 하자.
HTML 파일을 식별하기 위한 인스턴스 변수 path, 웹 사이트의 루트 디렉터리를 식별하기 위한 인스턴스 변수 homePath가 있는 클래스 WebPage를 정의하라. (이는 웹 사이트의 루트로부터 웹 사이트가 포함하는 파일로 올바르게 링크를 생성 시 필요할 것이다.) 클래스 측에 생성 메서드와 인스턴스 측에 초기화 메서드를 정의하라.
WebPage>>initializePath: filePath homePath: dirPath
path := filePath.
homePath := dirPath
WebPage class>>on: filePath forHome: homePath
^ self new initializePath: filePath homePath: homePath
WebDir 인스턴스는 그것이 포함하는 모든 웹 페이지의 목록을 리턴할 수 있어야 한다.
아래 메서드를 WebDir에 추가하고, 올바로 작동하는지 확인하기 위해 리턴값을 검사하라.
WebDir>>webPages
^ self htmlFiles collect:
[ :each | WebPage
on: webDir fullName, '/', each
forHome: homePath ]
아래와 같은 결과가 보일 것이다:
(WebDir onPath: '...') webPages → an Array(a WebPage a WebPage ...)
문자열 치환(substitution)
뜻을 정확히 알 수 없으므로 정규 표현식을 이용해 각 웹 페이지에 대한 실제 파일명을 얻도록 하자. 이를 위해서는 마지막 디렉터리까지의 경로명에서 모든 문자를 제거하고자 한다. Unix 파일 시스템에서 디렉터리는 슬래시(/)로 끝나므로 파일 경로에서 마지막 슬래시까지 모두 제거할 필요가 있다.
String 확장 메서드 copyWithRegex:matchesReplacedWith: 가 우리가 원하는 일을 수행한다:
'hello' copyWithRegex: '[elo]+' matchesReplacedWith: 'i' → 'hi'
이 예제에서 정규 표현식[elo]은 e, l, o 중 어떤 문자든 매치시킨다. 연산자 + 는 클리니 스타와 같지만 그 앞에 오는 정규 표현식의 하나 또는 그 이상의 인스턴스와 정확히 매치한다. 여기서는 전체 하위문자열 'ello'에 매치하여 i 문자로 된 새 문자열에서 다시 보일 것이다.
아래 메서드를 추가하고, 예상대로 작동하는지 검사하라.
WebPage>>fileName
^ path copyWithRegex: '.*/' matchesReplacedWith: ''
이제 자신의 테스트용 웹 사이트에 아래와 같은 내용이 보일 것이다:
(WebDir onPath: '...') webPages collect: [:each | each fileName ]
→ #('index.html' ...)
정규 표현식 매치 대상의 추출
다음 임무는 각 HTML 페이지의 제목을 추출하는 일이다.
먼저 각 페이지의 내용에 접근할 방법이 필요한데, 다음과 같은 방법이 간단하겠다.
WebPage>>contents
^ (FileStream oldFileOrNoneNamed: path) contents
사실상 자신의 웹 페이지가 ASCII가 아닌 문자를 포함한다면 문제가 있겠으나, 그런 경우 아래의 코드로 시작하는 편이 나을 것이다.
WebPage>>contents
^ (FileStream oldFileOrNoneNamed: path)
converter: Latin1TextConverter new;
contents
그러면 아래와 같은 내용을 볼 수 있을 것이다.
(WebDir onPath: '...') webPages first contents → '<head>
<title>Home Page</title>
...
'
이제 제목을 추출해보자. 이번 경우, 우리는 HTML 태그 <titles>와 </title> 사이에서 발생하는 텍스트를 보고 있다.
우리에게 필요한 것은 정규 표현식의 매치 부분을 추출하는 일이다. 정규 표현식의 하위표현식은 괄호로 구분된다. 정규 표현식 ([ˆaeiou]+)([aeiou]+)를 고려해보자. 이는 두 개의 하위표현식으로 구성되는데, 첫 번째는 하나 또는 그 이상의 비모음(non-vowels)의 시퀀스에 일치하고, 두 번째는 하나 또는 그 이상의 모음에 일치할 것이다 (괄호로 된 문자 집합의 시작에 위치한 연산자 ^는 집합을 부정한다.[3]).
이제 'pharo' 문자열의 접두사를 매치시키고 하위매치를 추출하도록 하겠다:
re := '([ˆaeiou]+)([aeiou]+)' asRegex.
re matchesPrefix: 'pharo' → true
re subexpression: 1 → 'pha'
re subexpression: 2 → 'ph'
re subexpression: 3 → 'a'
문자열에 대한 정규 표현식을 성공적으로 매치시키고 나면 전체 매치를 추출하기 위해 subexpression: 1 메시지를 언제든지 전송할 수 있다. subexpression: n 메시지도 전송할 수 있는데 여기서 n은 정규 표현식 내 하위표현식의 개수이다. 위의 정규 표현식에는 2와 3으로 번호 매겨진 두 개의 하위표현식이 있다.
같은 수법을 이용해 HTML 파일로부터 제목을 추출해보겠다.
WebPage>>title
| re |
re := '[\w\W]*<title>(.*)</title>' asRegexIgnoringCase.
^ (re matchesPrefix: self contents)
ifTrue: [ re subexpression: 2 ]
ifFalse: [ '(', self fileName, ' -- untitled)' ]
HTML은 태그가 대문자인지 소문자인지 신경 쓰지 않으므로 우리는 asRegexIgnoringCase를 인스턴스화함으로써 정규 표현식이 대·소문자에 민감하지 않도록 만들어야 한다.
이제 제목 추출기를 테스트할 수 있고, 아래와 같은 내용을 볼 것이다.
(WebDir onPath: '...') webPages first title → 'Home page'
추가 문자열 치환
사이트 맵을 생성하기 위해서는 각 웹 페이지로의 링크를 생성할 필요가 있겠다. 문서 제목을 링크의 이름으로 사용할 수 있다. 웹 사이트의 루트에서 웹 페이지에 대한 올바른 경로를 생성하면 된다. 다행히 손쉬운 작업인데, 웹 페이지에 대한 전체 경로에서 웹 사이트의 루트 디렉터리의 전체 경로를 제하기란 단순하기 때문이다.
한 가지만 주의하면 되겠다. HomePath 변수는 /로 끝나지 않기 때문에 하나를 추가해야만 상대 경로가 /로 시작하지 않을 것이다. 아래 두 결과의 차이를 주목하라.
'/home/testweb/index.html' copyWithRegex: '/home/testweb' matchesReplacedWith: ''
→ '/index.html'
'/home/testweb/index.html' copyWithRegex: '/home/testweb/' matchesReplacedWith: ''
→ 'index.html'
첫 번째 결과는 절대 경로, 어쩌면 우리가 원하는 결과를 제공할 것이다.
WebPage>>relativePath
^ path
copyWithRegex: homePath , '/'
matchesReplacedWith: ''
WebPage>>link
^ '<a href="', self relativePath, '">', self title, '</a>'
이제 아래와 같은 결과를 확인할 것이다.
(WebDir onPath: '...') webPages first link → '<a href="index.html">Home Page</a>'
사이트 맵 생성하기
사실상 사이트 맵을 생성하는 데에 필요한 정규 표현식은 끝이 난 셈이다. 애플리케이션을 완성하기 위해서는 몇 가지 메서드만 필요하다.
사이트 맵 생성을 보고 싶다면 아래 메서드를 추가하기만 하면 된다.
우리 웹 사이트에 하위디렉터리가 있다면 하위디렉터리로 접근하는 방법이 필요하다.
WebDir>>webDirs
^ webDir directoryNames
collect: [ :each | WebDir onPath: webDir pathName , '/' , each home: homePath ]
웹 디렉터리의 각 웹 페이지에 대한 링크를 포함하는 HTML bullet 리스트를 생성할 필요가 있다. 하위디렉터리는 자체 bullet 리스트에서 들여쓰기가 되어 있어야 한다.
WebDir>>printTocOn: aStream
self htmlFiles
ifNotEmpty: [
aStream nextPutAll: '<ul>'; cr.
self webPages
do: [:each | aStream nextPutAll: '<li>';
nextPutAll: each link;
nextPutAll: '</li>'; cr].
self webDirs
do: [:each | each printTocOn: aStream].
aStream nextPutAll: '</ul>'; cr]
루트 웹 디렉터리에 "toc.html"이라는 파일을 생성하고 그곳에 사이트 맵을 덤프(dump)한다.
WebDir>>tocFileName
^ 'toc.html'
WebDir>>makeToc
| tocStream |
tocStream := (webDir / self tocFileName) writeStream.
self printTocOn: tocStream.
tocStream close.
이제 임시 웹 디렉터리에 대한 내용의 테이블을 생성할 수 있다!
WebDir selectHome makeToc
정규 표현식 구문
이제 Regex 패키지가 지원하는 정규 표현식의 구문을 자세히 살펴볼 것이다.
가장 단순한 정규 표현식은 단일 문자이다. 이는 해당 문자에 정확히 매치한다. 문자의 시퀀스는 문자의 시퀀스와 정확히 동일한 문자열을 매치한다:
'a' matchesRegex: 'a' → true
'foobar' matchesRegex: 'foobar' → true
'blorple' matchesRegex: 'foobar' → false
연산자는 좀 더 복잡한 정규 표현식을 생성하도록 정규 표현식에 적용된다. 연산자로서 순서화(sequencing; 표현식을 연이어 열거하는)는 "보이지 않는(invisible)" 경우가 보통이다.
클리니 스타(*)와 + 연산자는 이미 살펴보았다. 정규 표현식 다음에 붙는 별표(*)는 원본 표현식의 매치 중 어떤 숫자(0을 포함)에든 매치한다. 예를 들자면 다음과 같다.
'ab' matchesRegex: 'a*b' → true
'aaaaab' matchesRegex: 'a*b' → true
'b' matchesRegex: 'a*b' → true
'aac' matchesRegex: 'a*b' → false "b does not match"
클리니 스타는 시퀀싱보다 우선순위가 높다. 별표는 그보다 앞설 수 있는 가장 짧은 하위표현식에 적용된다. 예를 들어, ab* 는 "ab의 0번 또는 그 이상의 발생"이 아니라 a 다음에 b의 0번 또는 그 이상의 발생을 의미한다.
'abbb' matchesRegex: 'ab*' → true
'abab' matchesRegex: 'ab*' → false
"ab의 0번 또는 그 이상의 발생" 과 일치하는 정규 표현식을 얻기 위해서는 ab를 괄호로 감싸야 한다.
'abab' matchesRegex: '(ab)*' → true
'abcab' matchesRegex: '(ab)*' → false "c spoils the fun"
*와 비슷하면서 유용한 연산자로 두 가지, +와 ?가 있다. +는 그것이 수정하는 정규 표현식의 하나 또는 그 이상의 인스턴스를 매치하고, ?는 0개 또는 하나의 인스턴스를 매치할 것이다.
'ac' matchesRegex: 'ab*c' → true
'ac' matchesRegex: 'ab+c' → false "need at least one b"
'abbc' matchesRegex: 'ab+c' → true
'abbc' matchesRegex: 'ab?c' → false "too many b's"
살펴보았듯 *, +, ?, (, 와) 문자들은 정규 표현식 내에서 특별한 의미를 가진다. 그 중 어떤 것이라도 문자 그대로 매치하기 위해서는 그 앞에 백슬래시\를 붙임으로써 escape시켜야 한다. 따라서 백슬래시도 특수 문자에 해당하며, 리터럴 매치에 대해 escape할 필요가 있다. 우리가 살펴볼 모든 특수 문자에도 마찬가지로 적용된다.
'ab*' matchesRegex: 'ab*' → false "star in the right string is special"
'ab*' matchesRegex: 'ab\*' → true
'a\c' matchesRegex: 'a\\c' → true
마지막 연산자ㅣ는 두 개의 하위표현식 사이에 선택을 나타낸다. 이는 두 하위표현식 중 하나가 문자열에 매치할 경우 문자열에 매치한다. 해당 연산자의 우선순위는 가장 낮으며, 순서화(sequencing)보다도 낮다. 가령, ab*|ba* 는 "a 다음에 b의 어떤 개수든 따라올 수 있거나, b 다음에 a의 어떤 개수든 따라올 수 있음"을 의미한다.
'abb' matchesRegex: 'ab*|ba*' → true
'baa' matchesRegex: 'ab*|ba*' → true
'baab' matchesRegex: 'ab*|ba*' → false
조금 더 복잡한 예로, 표현식 c(a|d)+r 를 들 수 있는데, 이는 Lips-style car, cdr, caar, cadr, ... 함수 중 어떤 이름이든 매치한다.
'car' matchesRegex: 'c(a|d)+r' → true
'cdr' matchesRegex: 'c(a|d)+r' → true
'cadr' matchesRegex: 'c(a|d)+r' → true
빈 문자열을 매치하는 표현식을 작성하는 것도 가능한데, 가령 aㅣ 표현식은 빈 문자열을 매치한다. 하지만 그러한 표현식에 , +, 또는 ?를 적용시키는 것은 오류이며, (aㅣ) 는 무효하다.
지금까지 정규 표현식의 가장 작은 구성요소로서 문자만 사용해왔다. 그 외에 다른 흥미로운 구성요소들도 있다. 문자 집합은 사각 괄호로 된 문자열이다. 이는 괄호 사이에 나타날 경우 어떤 단일 문자든 매치한다. 예를 들자면, [01]는 0 또는 1을 매치한다:
'0' matchesRegex: '[01]' → true
'3' matchesRegex: '[01]' → false
'11' matchesRegex: '[01]' → false "a set matches only one character"
플러스 연산자를 이용해 다음과 같은 이진수 인식기(recognizer)를 빌드할 수도 있겠다:
'10010100' matchesRegex: '[01]+' → true
'10001210' matchesRegex: '[01]+' → false
여는 괄호 다음에 오는 첫 번째 문자가 ^라면 집합은 역전(inverted)되어, 괄호 안에 없는 어떤 단일 문자든 매치한다.
'0' matchesRegex: '[^01]' → false
'3' matchesRegex: '[^01]' → true
편의상 집합은 범위를 포함할 수 있는데, 범위란 하이픈(-)으로 구분된 문자 쌍이다. 이는 가운데 위치한 모든 문자를 열거하는 것과 같아서 '[0-9]'는 '[0123456789]'와 같다. 집합 내 특수 문자는 ^, -, 집합을 닫는 ]가 있다. 아래는 집합 내 특수문자를 매치하는 방법을 예로 든 것이다.
'^' matchesRegex: '[01^]' → true "put the caret anywhere except the start"
'-' matchesRegex: '[01-]' → true "put the hyphen at the end"
']' matchesRegex: '[]01]' → true "put the closing bracket at the start"
따라서 빈 집합과 전체 집합은 명시할 수 없다.
구문 | 구문이 표현하는 대상 |
a | 문자 a의 리터럴 매치 |
. | 어떤 char든 매치 |
(...) | 그룹 하위표현식 |
\ | 뒤에 오는 특수 문자를 escape |
* | 클리니 스타 - 이전 정규 표현식을 0번 또는 그 이상 매치 |
? | 이전 정규 표현식을 0번 또는 한 번 매치 |
좌측이나 우측 정규 표현식 선택을 매치 | |
[abcd] | 문자 abcd의 선택을 매치 |
[^abcd] | 문자의 부정된 선택을 매치 |
[0-9] | 0부터 9까지 문자 범위를 매치 |
\w | 영숫자(alphanumeric)를 매치 |
\W | 영숫자가 아닌 대상을 매치 |
\d | digit를 매치 |
\D | digit가 아닌 대상을 매치 |
\s | 공백을 매치 |
\S | 공백이 아닌 대상을 매치 |
표 6.1: 정규 표현식의 간단한 구문 |
Character 클래스
정규 표현식은 아래와 같은 역다옴표 escape를 포함시켜 잘 사용되는 문자의 클래스를 참조할 수 있다: \w는 digit를 매치하고, \s는 공백을 매치한다. 이를 대문자로 변형한 \W, \D, \S는 complementary(채움) 문자를 매치한다 (영수자가 아닌, digit가 아닌, 공백이 아닌 대상을 매치). 지금까지 살펴본 구문의 요약본은 표 6.1에서 볼 수 있겠다.
서론에서 언급하였듯 정규 표현식은 특히 사용자 입력을 검증하는 데에 유용하며, 그러한 정규 표현식을 정의하는 데에는 특히 character 클래스가 유용한 것으로 밝혀졌다. 예를 들어, 음수가 아닌 숫자는 정규 표현식 d+ 를 이용해 매치할 수 있다.
'42' matchesRegex: '\d+' → true
'-1' matchesRegex: '\d+' → false
오히려 0이 아닌 숫자는 0으로 시작해선 안 됨을 명시하는 편이 낫겠다.
'0' matchesRegex: '0|([1-9]\d*)' → true
'1' matchesRegex: '0|([1-9]\d*)' → true
'42' matchesRegex: '0|([1-9]\d*)' → true
'099' matchesRegex: '0|([1-9]\d*)' → false "leading 0"
음수와 양수 또한 검사할 수 있겠다.
'0' matchesRegex: '(0|((\+|-)?[1-9]\d*))' → true
'-1' matchesRegex: '(0|((\+|-)?[1-9]\d*))' → true
'42' matchesRegex: '(0|((\+|-)?[1-9]\d*))' → true
'+99' matchesRegex: '(0|((\+|-)?[1-9]\d*))' → true
'-0' matchesRegex: '(0|((\+|-)?[1-9]\d*))' → false "negative zero"
'01' matchesRegex: '(0|((\+|-)?[1-9]\d*))' → false "leading zero"
부동 소수점 수는 점 다음에 적어도 하나의 숫자를 필요로 한다.
'0' matchesRegex: '(0|((\+|-)?[1-9]\d*))(\.\d+)?' → true
'0.9' matchesRegex: '(0|((\+|-)?[1-9]\d*))(\.\d+)?' → true
'3.14' matchesRegex: '(0|((\+|-)?[1-9]\d*))(\.\d+)?' → true
'-42' matchesRegex: '(0|((\+|-)?[1-9]\d*))(\.\d+)?' → true
'2.' matchesRegex: '(0|((\+|-)?[1-9]\d*))(\.\d+)?' → false "need digits after ."
999 혹은 999.999 혹은 -999.999e+21 과 같이 무엇이든 해당하는 일반 숫자 형식에 대한 인식기를 간단한 예로 들자.
구문 | 구문이 표현하는 대상 |
[:alnum:] | 어떤 영숫자든 가능 |
[:alpha:] | 어떤 영문자든 가능 |
[:cntrl:] | 어떤 제어 문자든 가능 (ascii 코드<32) |
[:digit:] | 어떤 10진 숫자든 가능 |
[:graph:] | 어떤 그래픽(graphical) 문자든 가능 (ascii 코드≥32) |
[:lower:] | 어떤 소문자든 가능 |
[:print:] | 어떤 인쇄 가능 문자든 가능 (여기서는 [:graph:]와 동일) |
[:punct:] | 어떤 구두 문자든 가능 |
[:space:] | 어떤 공백 문자든 가능 |
[:upper:] | 어떤 대문자든 가능 |
[:xdigit:] | 어떤 16진 문자든 가능 |
표 6.2: 정규 표현식 character 클래스 |
이러한 요소들은 character 클래스의 구성요소여서 유효한 정규 표현식을 형성하기 위해서는 사각 괄호 안에 포함시켜야 함을 주목하라. 예를 들어, 비어 있지 않은 digit 문자열은 [[:digit:]]+ 로 표현할 수 있겠다. 위의 프리미티브 표현식과 연산자는 정규 표현식의 많은 구현에 공통된다.
'42' matchesRegex: '[[:digit:]]+' → true
특수 문자 클래스
다음 프리미티브 표현식은 스몰토크 구현에 유일하다. 콜론 사이의 문자 시퀀스는 단항 선택자로서 취급되는데, 이는 문자들이 이해해야 한다. 문자는 그 선택자를 이용해 메시지로 true를 응답할 경우 그러한 표현식에 매치한다. 이는 character 클래스를 좀 더 쉽게 읽을 수 있고 효율적인 방식으로 명시하도록 허용한다. 예를 들어, [0-9]는 :isDigit: 와 같지만 후자가 더 효율적이다. Character 집합과 동일하게 character 클래스도 부정이 가능하여, ^isDigit: 는 isDigit에 false로 답하는 문자를 매치시키므로 [^0-9]와 같다.
지금까지 비어 있지 않은 digit의 문자열을 매치하는 정규 표현식을 작성하는 방법으로 [0-9]+, d+, [\d]+, digit:+, :isDigit:+를 살펴보았다.
'42' matchesRegex: '[0-9]+' → true
'42' matchesRegex: '\d+' → true
'42' matchesRegex: '[\d]+' → true
'42' matchesRegex: '[[:digit:]]+' → true
'42' matchesRegex: ':isDigit:+' → true
경계 매치하기
특수 프리미티브 표현식의 마지막 그룹은 표 6.3에 표시되어 있는데, 이는 문자열의 경계를 매치하는 데에 사용된다.
구문 | 구문이 표현하는 대상 |
^ | 행의 시작에 빈 문자열을 매치 |
$ | 행의 끝에 빈 문자열을 매치 |
\b | 단어 경계에서 빈 문자열을 매치 |
\B | 단어 경계가 아닌 곳에서 빈 문자열을 매치 |
\< | 단어 시작에 빈 문자열을 매치 |
\> | 단어 끝에 빈 문자열을 매치 |
표 6.3: 문자열 경계를 매치하기 위한 프리미티브 |
'hello world' matchesRegex: '.*\bw.*' → true "word boundary before w"
'hello world' matchesRegex: '.*\bo.*' → false "no boundary before o"
정규 표현식 API
지금까지는 주로 정규 표현식의 구문에 중점을 두었다. 이제 문자열과 정규 표현식이 이해하는 여러 메시지들을 자세히 살펴보겠다.
접두사의 매칭과 대·소문자 무시
여태껏 보인 예제들 대부분은 String 확장자 메서드 matchesRegex: 를 사용해왔다.
문자열 또한 prefixMatchesRegex:, matchesRegexIgnoringCase:, prefixMatchesRegexIgnoringCase: 를 이해한다.
PrefixMatchesRegex: 메시지는 matchesRegex 와 같지만 전체 수신자가 인자로서 전달된 정규 표현식을 매치할 것으로 기대하지 않고 그 접두사만 매치한다는 점에서 다르다.
'abacus' matchesRegex: '(a|b)+' → false
'abacus' prefixMatchesRegex: '(a|b)+' → true
'ABBA' matchesRegexIgnoringCase: '(a|b)+' → true
'Abacus' matchesRegexIgnoringCase: '(a|b)+' → false
'Abacus' prefixMatchesRegexIgnoringCase: '(a|b)+' → true
열거 인터페이스
몇몇 애플리케이션은 문자열 내 특정 정규 표현식의 모든 매치로 접근해야 할 필요가 있다. 매치는 친숙한 Collection과 같은 열거형 프로토콜을 본따 모델화된 프로토콜을 이용해 접근이 가능하다.
regex:matchesDo: 는 수신자 문자열 내 정규 표현식의 매치마다 1인자 aBlock을 평가한다.
list := OrderedCollection new.
'Jack meet Jill' regex: '\w+' matchesDo: [:word | list add: word].
list → an OrderedCollection('Jack' 'meet' 'Jill')
regex:matchesCollect: 는 수신자 문자열 내 정규 표현식의 매치마다 1인자 aBlock을 평가한다. 이후 결과를 수집하여 SequenceableCollection으로서 응답한다.
'Jack meet Jill' regex: '\w+' matchesCollect: [:word | word size] →
an OrderedCollection(4 4 4)
allRegexMatches: 는 정규 표현식에 대한 모든 매치의 (수신자 문자열의 하위문자열) 컬렉션을 리턴한다.
'Jack and Jill went up the hill' allRegexMatches: '\w+' →
an OrderedCollection('Jack' 'and' 'Jill' 'went' 'up' 'the' 'hill')
대체와 번역
copyWithRegex:matchesReplacedWith:를 이용해 정규 표현식의 모든 매치를 특정 문자열로 대체하는 것이 가능하다.
'Krazy hates Ignatz' copyWithRegex: '\<[[:lower:]]+\>' matchesReplacedWith: 'loves'
→ 'Krazy loves Ignatz'
좀 더 일반적인 대체는 매치의 번역이다. 이 메시지는 블록을 평가하고 이를 수신자 문자열 내 정규 표현식의 매치마다 전달하며, 각 매치 대신 블록 결과를 이은 수신자의 복사본을 응답한다.
'Krazy loves Ignatz' copyWithRegex: '\b[a-z]+\b' matchesTranslatedUsing: [:each | each asUppercase]
→ 'Krazy LOVES Ignatz'
열거 및 대체 프로토콜의 모든 메시지들은 대·소문자에 민감한 매치를 수행한다. 대·소문자에 둔감한 버전은 String 프로토콜의 일부로 제공되지 않는다. 대신 아래 질문에 제시된 저수준 매칭 인터페이스를 이용해 접근이 가능하다.
저수준 인터페이스
matchesRegex: 메시지를 문자열로 전송하면 다음과 같은 일이 발생한다.
- RxParser의 새로운 인스턴스가 생성되고, 정규 표현식 문자열이 그곳으로 전달되어 표현식의 구문 트리가 생성된다.
- 구문 트리는 RxMatcher의 인스턴스에 initialization 매개변수로서 전달된다. 인스턴스는 트리가 설명한 정규 표현식에 대한 인식기로서 작용하게 될 데이터 구조를 준비한다.
- 원본 문자열이 matcher로 전달되고, matcher는 매치를 확인한다.
Matcher
String에 정의된 메시지들 중 하나를 이용해 다수의 문자열을 동일한 정규 표현식에 대해 반복적으로 매치할 경우, 매치마다 정규 표현식 문자열이 파싱되고 새로운 matcher가 생성된다. 이러한 오버헤드는 정규 표현식에 대한 matcher를 빌드한 후 matcher를 반복하여 재사용함으로써 피할 수 있다. 예를 들어, 클래스 또는 인스턴스 초기화 단계에서 matcher를 생성하고, 이를 추후 사용을 위해 변수에 보관하라. 다음 방법들 중 하나를 이용해 matcher를 생성할 수 있겠다.
- 문자열로 asRegex 또는 asRegexIgnoringCase를 전송할 수 있다.
- 그 클래스 메서드들, forString: 또는 forString:ignoreCase: 중 하나를 이용해 RxMatcher를 직접 인스턴스화할 수 있다 (위의 편리한 메서드가 하는 일을 할 것이다).
문자열에서 발견되는 모든 매치를 수집하도록 matchesIn: 을 전송한다.
octal := '8r[0-9A-F]+' asRegex.
octal matchesIn: '8r52 = 16r2A' → an OrderedCollection('8r52')
hex := '16r[0-9A-F]+' asRegexIgnoringCase.
hex matchesIn: '8r52 = 16r2A' → an OrderedCollection('16r2A')
hex := RxMatcher forString: '16r[0-9A-Fa-f]+' ignoreCase: true.
hex matchesIn: '8r52 = 16r2A' → an OrderedCollection('16r2A')
매칭
matcher는 이러한 메시지들을 이해한다 (모두들 성공적인 매치나 검색을 나타내기 위해 true를 리턴하고, 그렇지 않은 경우 false를 답한다).
matches: aString - 전체 인자 문자열(aString)이 매치할 경우 true.
'\w+' asRegex matches: 'Krazy' → true
matchesPrefix: aString - 인자 문자열의 일부 접두사가 매치할 경우 true (전체 문자열이 매치할 필요는 없다).
'\w+' asRegex matchesPrefix: 'Ignatz hates Krazy' → true
search: aString - 하위문자열이 처음으로 매치하는 문자열을 검색한다. (첫 두 개의 문자열은 문자열 처음부터 매치를 시도함을 주목하라.) matcher를 이용해 위의 예제에서 a+ 를 검색할 경우 해당 메서드는 'baaa' 문자열이 주어지면서 성공을 답하겠지만 앞의 두 개는 실패할 것이다.
'\b[a-z]+\b' asRegex search: 'Ignatz hates Krazy' → true "finds 'hates'"
matcher는 마지막 매치 시도의 결과도 보관하고 보고(report)하기도 한다: lastResult는 Boolean을 답했다: 가장 최근에 시도한 매치의 결과. 어떤 매치도 시도되지 않은 경우 답은 명시되지 않는다.
number := '\d+' asRegex.
number search: 'Ignatz throws 5 bricks'.
number lastResult → true
matchesStream:, matchesStreamPrefix:, searchStream: 는 위의 세 메시지와 같지만 스트림을 인자로서 취한다.
ignatz := ReadStream on: 'Ignatz throws bricks at Krazy'.
names := '\<[A-Z][a-z]+\>' asRegex.
names matchesStreamPrefix: ignatz → true
하위표현식 매치
매치 시도가 성공하고 나면 원본 문자열의 어떤 부분이 어떤 정규 표현식 부분에 매치하였는지 질의할 수 있다. 하위표현식은 정규 표현식에서 괄호로 표시되거나, 전체 표현식이 될 수도 있다. 정규 표현식이 컴파일되면 그 하위표현식은 1부터 시작하는 깊이 우선의 좌우(depth-first, left-to-right)로 할당된 색인이 된다.
예를 들어, 정규 표현식 ((\\d+)\\s*(\\w+)) 에는 그 자체를 포함해 4개의 하위표현식이 있다.
1: ((\d+)\s*(\w+)) "the complete expression"
2: (\d+)\s*(\w+) "top parenthesized subexpression"
3: \d+ "first leaf subexpression"
4: \w+ "second leaf subexpression"
가장 높은 유효 색인은 1에 매칭 괄호 개수를 더한 값과 같다. 따라서 1은 괄호로 감싼 하위표현식이 없더라도 항상 유효한 색인이다).
매치가 성공하고 나면 matcher는 원본 문자열의 어느 부분이 어느 하위표현식에 매치하였는지를 보고할 수 있다. matcher는 다음과 같은 메시지들을 이해한다:
subexpressionCount는 전체 하위표현식 개수를 응답하고, 최고 값은 해당 matcher와 하위표현식 색인으로서 사용 가능하다. 해당 값은 초기화 직후에 이용 가능하며 절대 변경되지 않는다.
subexpression: 는 유효 색인을 그 인자로서 취하고, 성공적인 매치 시도 이후에만 전송이 가능하다. 메서드는 해당하는 하위표현식을 매치시켜야 하는 원본 문자열의 하위문자열을 응답한다.
subBeginning: 과 subEnd: 는 주어진 하위표현식 매치가 각각 시작되고 끝나는 인자 문자열 또는 스트림 내에서 위치를 응답한다.
items := '((\d+)\s*(\w+))' asRegex.
items search: 'Ignatz throws 1 brick at Krazy'.
items subexpressionCount → 4
items subexpression: 1 → '1 brick' "complete expression"
items subexpression: 2 → '1 brick' "top subexpression"
items subexpression: 3 → '1' "first leaf subexpression"
items subexpression: 4 → 'brick' "second leaf subexpression"
items subBeginning: 3 → an OrderedCollection(14)
items subEnd: 3 → an OrderedCollection(15)
items subBeginning: 4 → an OrderedCollection(16)
items subEnd: 4 → an OrderedCollection(21)
좀 더 세부적인 예로, 일자를 3 요소 배열, 즉 연도, 월, 일의 문자열로 변환하기 위해 MMM DD, YYYY 날짜 포맷을 이용하는 아래 예제를 들어보자.
date := '(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+(\d\d?)\s*,\s*19(\d\d)' asRegex.
result := (date matches: 'Aug 6, 1996')
ifTrue: [{ (date subexpression: 4) .
(date subexpression: 2) .
(date subexpression: 3) } ]
ifFalse: ['no match'].
result → #('96' 'Aug' '6')
열거 및 대체
이번 절 앞 부분에서 살펴본 String 열거 및 대체 프로토콜은 사실상 matcher에 의해 구현된다. RxMatcher는 문자열 내 매치를 반복하기 위해 matchesIn:, matchesIn:do:, matchesIn:collect:, copy:replacingMatchesWith:, 그리고 copy:translatingMatchesUsing: 과 같은 메서드들을 구현한다.
seuss := 'The cat in the hat is back'.
aWords := '\<([^aeiou]|[a])+\>' asRegex. "match words with 'a' in them"
aWords matchesIn: seuss
→ an OrderedCollection('cat' 'hat' 'back')
aWords matchesIn: seuss collect: [:each | each asUppercase ]
→ an OrderedCollection('CAT' 'HAT' 'BACK')
aWords copy: seuss replacingMatchesWith: 'grinch'
→ 'The grinch in the grinch is grinch'
aWords copy: seuss translatingMatchesUsing: [ :each | each asUppercase ]
→ 'The CAT in the HAT is BACK'
스트림 내에서 매치를 반복하는 matchesOnStream:, matchesOnStream:do:, matchesOnStream:collect:, copyStream:to:replacingMatchesWith:, copyStream:to:translatingMatchesUsing: 와 같은 메서드들도 존재한다.
in := ReadStream on: '12 drummers, 11 pipers, 10 lords, 9 ladies, etc.'.
out := WriteStream on: ''.
numMatch := '\<\d+\>' asRegex.
numMatch
copyStream: in
to: out
translatingMatchesUsing: [:each | each asNumber asFloat asString ].
out close; contents → '12.0 drummers, 11.0 pipers, 10.0 lords, 9.0 ladies, etc.'
오류 처리
정규 표현식을 빌드하는 동안에 RxParser가 발생시키는 예외가 몇 가지 있다. 예외는 RegexError를 공통 부모로 갖는다. 이러한 예외를 포착하고 처리하기 위해 일반적인 스몰토크 예외 처리 메커니즘을 사용할 수 있다.
- 정규 표현식을 파싱하는 동안 구문 오류가 감지될 경우 RegexSyntaxError가 발생한다.
- matcher를 빌드하는 동안 오류가 감지될 경우 RegexCompilationError가 발생한다.
- 매치하는 동안 오류가 발생할 경우 (예를 들어, ':<selector>:' 구문을 이용해 올바르지 않은 선택자가 명시되거나, matcher의 내부 오류로 인해) RegexMatchingError 가 발생한다.
['+' asRegex] on: RegexError do: [:ex | ^ ex printString ]
→ 'RegexSyntaxError: nullable closure'
Vassili Bykov의 구현 노트
먼저 확인해야 할 것. 90% 는 String>>matchesRegex: 메서드만으로 패키지에 접근이 가능할 것이다. RxParser는 정규 표현식과 함께 문자의 스트림이나 문자열을 수락하고, 표현식에 해당하는 구문 트리를 생산한다. 트리는 Rxs* 클래스로 구성된다.
RxMatcher는 파서가 빌드한 정규 표현식의 구문 트리를 수락하고, 이를 Rxm* 클래스의 인스턴스로 만들어진 구조인 matcher로 컴파일한다. RxMatcher 인스턴스는 문자의 위치지정 가능한 스트림 또는 문자열이 본래 정규 표현식에 매치하는지 테스트하거나 표현식에 매치하는 하위문자열을 스트림이나 문자열에서 검색할 수 있다. 매치가 발견되면 matcher는 전체 표현식에 매치하거나 그 중에서 괄호로 표기된 하위표현식에 매치한 특정 문자열을 보고할 수 있다. 다른 모든 클래스들도 동일한 기능을 지원하며, RxParser, RxMatcher, 또는 둘 다에 의해 사용된다.
통고. matcher는 C 에서 Henry Spencer의 원본 정규 표현식 구현과 정신은 비슷하지만 디자인은 다르다. 효율성이 아니라 단순성에 중점을 두었다. 필자는 어떤 것도 최적화하거나 작성하지 않았다. matcher가 H. Spencer의 테스트 도구("test suite" 프로토콜 참조)에 몇 가지 테스트를 추가해 전달하므로 그다지 많은 버그는 없을 것으로 사료된다.
감사의 말. matcher의 첫 발표 이후 여러 스몰토크 동료들의 투입에 감사하게도 native Smalltalk 정규 표현식 match를 살려두기 위해 노력할만한 가치가 있음을 확신하게 되었다. 이것이 가능하도록 조언과 격려를 준 Felix Hack, Eliot Miranda, Robb Shecter, David N. Smith, Francis Wolinski, 그리고 아직 만나거나 이야기를 들은 바는 없지만 온전한 시간 낭비가 아니었음을 동의하는 이들에게 감사의 말을 전한다.
요약
정규 표현식은 문자열을 쉽게 조작하는 데 필수적인 툴이다. 본 장에서는 Pharo용 Regex 패키지를 제시하였다. 이번 장의 요점은 다음과 같다.
- 간단한 매칭을 위해서는 문자열로 matchesRegex: 만 전송하라.
- 성능이 중요하면 정규 표현식을 표현하는 문자열로 asRegex를 전송하고, 다수의 매치에 대해 결과 matcher를 재사용하라.
- 매치하는 정규 표현식의 하위표현식은 임의 깊이(arbitrary depth)로 쉽게 검색이 가능하다.
- 매치하는 정규 표현식은 매치된 문자열의 새 복사본에 하위표현식을 번역 또는 대체할 수 있다.
- 특정 정규 표현식의 모든 매치로 접근하도록 열거 인터페이스가 제공된다.
- 정규 표현식은 문자열 뿐만 아니라 스트림과도 작동한다.
Notes
- ↑ http://en.wikipedia.org/wiki/Regular_expression
- ↑ 원본 문서는 RxParser의 클래스 측에서 찾아볼 수 있다.
- ↑ NB: Pharo에서 탈자 기호는 리턴 키워드, 즉 ^에 해당하기도 한다. 혼동을 막기 위해, 문자 집합을 부정 시 정규 표현식 내에서 탈자 기호를 사용할 때 ^를 쓰겠지만 사실상 두 가지가 동일함을 잊어선 안 된다.