저는 점프투파이썬 교재로 공부합니다. 이 교재를 바탕으로 공부한 걸 정리합니다.
1️⃣ 정규표현식이란? 문자 클래스, Dot(.), 반복(*), 반복(+), 반복({m,n}, ?)
2️⃣ 정규 표현식(2) - 문자열 검색(match, search, findall, finditer)
3️⃣ 정규표현식(3) - match 객체의 메서드(group, start, end, span)
4️⃣ 정규표현식(4) - COMPILE 옵션 - (DOTALL, IGNORECASE, MULTILINE, VERBOSE)
5️⃣ 정규 표현식(5) - 백슬래시 문제('\n')
6️⃣ 정규 표현식(6) 메타 문자 - |, ^, $, \A, \Z, \b, \B
7️⃣ 정규표현식(7) 그루핑, 전방 탐색, 문자열 바꾸기, Greedy vs Non-Greedy
ABC 문자열이 계속해서 반복되는지 조사하는 정규식을 작성하고 싶습니다. 어떻게 하면 좋을까요?
여기서 새로 나오는 개념이 그루핑(Grouping)입니다.
(ABC)+
그룹을 만들어주는 메타 문자는 바로 ( ) 입니다!
import re
p = re.compile('(ABC)+')
m = p.search("ABCABCABC OK?")
print(m)
print(m.group(0))
<re.Match object; span=(0, 9), match='ABCABCABC'>
ABCABCABC
반복되는 'ABCABCABC'를 잘 찾았고, Grouping이 그 값을 뽑아주었습니다.
import re
p = re.compile(r"\w+\s+\d+[-]\d+[-]\d+")
m = p.search("park 010-1234-1234")
print(m)
<re.Match object; span=(0, 18), match='park 010-1234-1234'>
"\w+\s+\d+[-]\d+[-]\d+" 해당 정규식은 '이름 + "" + 전화번호" 형태의 문자열을 찾는 정규식입니다.
여기서 매치된 문자열 중에 이름만 뽑아내고 싶다면 어떻게 해야 할까요?
반복되는 문자열을 찾을 때 그룹을 사용합니다. 그룹을 사용하는 보다 큰 이유는 위에서 볼 수 있듯이 매치된 문자열 중에서 특정 부분의 문자열만 뽑아내기 위해서인 경우가 더 많습니다.
이름과 전화번호를 따로 뽑아보겠습니다.
import re
p = re.compile(r'(\w+)\s+(\d+[-]\d+[-]\d+)')
m = p.search('park 010-1234-1234')
print(m.group(1))
print(m.group(2))
park
010-1234-1234
위의 정규식과 달라진 점은 무엇인가요?
r"\w+\s+\d+[-]\d+[-]\d+"
r'(\w+)\s+(\d+[-]\d+[-]\d+)'
저도 한 눈에 보이지 않아서 error를 한 10번은 내고 말았습니다.🤣
찾으셨나요?
'\w+' vs '(\w+)'
앞의 해당 부분에 ( ) 괄호가 추가되었는데 여기선느 이를 그룹으로 만들어주었다고 말합니다.
'(\w+)' 그룹으로 만들면, match 객체의 group(인덱스)에서 메서드를 사용하여 그루핑된 부분의 문자열만 뽑아낼 수 있습니다.
그리고 뒤에도 괄호가 추가되었습니다.
(\d+[-]\d+[-]\d+)
이 부분은 '숫자-숫자-숫자' 형태의 전화번호를 그루핑 해준 결과라 생각할 수 있습니다.
그런데 우리는 이름과 전화 번호를 불러오기 위해
print(m.group(1))
print(m.group(2))
위와 같이 출력했습니다. 뭔가 인덱스 0부터 시작해야 될 것만 같은 기분인데🤔 그루핑은 그렇지 않습니다.
group(인덱스) | 설명 |
---|---|
group(0) | 매치된 전체 문자열 |
group(1) | 첫 번째 그룹에 해당하는 문자열 |
group(2) | 두 번째 그룹에 해당하는 문자열 |
group(n) | n 번째 그룹에 해당하는 문자열 |
이렇게 설명을 들었으니 한 번 전체 문자열을 불러와보겠습니다.
import re
p = re.compile(r'(\w+)\s+(\d+[-]\d+[-]\d+)')
m = p.search('park 010-1234-1234')
print(m.group(0))
park 010-1234-1234
원하는 결과가 잘 나왔습니다.
국번이 무엇인지 뽑아보겠습니다.
import re
p = re.compile(r'(\w+)\s+((\d+)[-]\d+[-]\d+)')
m = p.search('park 010-1234-1234')
print(m.group(0))
print(m.group(1))
print(m.group(2))
print(m.group(3))
park 010-1234-1234
park
010-1234-1234
010
위와 같이 결괏값이 나옵니다.
r'(\w+)\s+((\d+)[-]\d+[-]\d+)'
해당 부분만 수정하였으며 중첩된 경우는 인덱스가 가장 마지막 번호로 들어갔습니다. 그루핑의 규칙이라고 합니다.
그룹이 중첩되어 있는 경우 바깥쪽부터 시작하여 안쪽으로 들어갈 수록 인덱스가 증가한다.
import re
p = re.compile(r'(\b\w+)\s+\1')
print(p.search('Paris in the the spring').group())
the the
그룹의 또 다른 좋은 점은 한 번 그루핑한 문자열을 재참조(Backreferences)할 수 있다는 점입니다.
정규식 '(\b\w+)\s+\1'은 '(그룹)+**+그룹과 동일한 단어'와 매치됩니다.
이렇게 정규식을 만들게 되면 2개의 동일한 단어를 연속적으로 사용해야만 매치 됩니다.
이걸 가능하게 해주는 건 재참조 문자인 \1 입니다. \1은 정규식 그룹 중 첫 번째 그룹을 가리킵니다.
정규식 안에 그룹이 많아진다면 🤯 머리가 터지겠죠.
정규식이 수정되고 그룹이 추가, 삭제 등을 원할 때 이 그룹을 참조한 프로그램도 모두 변경해주어야 할 수도 있습니다.
만약 그룹을 인덱스가 아닌 이름으로 참조할 수 있다면 어떨까요? 그러면 변경의 문제에서 조금 벗어날 수 있지 않을까 싶어 만들어진 것이라고 합니다.
import re
p = re.compile(r'(?P<name>\w+)\s+((\d+)[-]\d+[-]\d+)')
m = p.search('park 010-1234-1234')
print(m.group('name'))
park
해당 정규식에서 달라진 점은
(\w+)
👉(?P<name>\w+)
해당 부분입니다.
여기는 그룹에 name 이라고 이름 붙인 것에 불과합니다. 앞의 물음표(?)는 정규 표현식의 확장 구문입니다.
그룹에 이름을 지어주기 위해서는 확장 구문은 필수입니다.
그래서 print(m.group('name')) 을 입력했을 경우 park가 결괏값으로 출력됩니다.
이것도 재참조가 가능합니다.
import re
p = re.compile(r'(?P<word>\b\w+)\s+(?P=word)')
print(p.search('Paris in the the spring').group())
the the
재참조시엔 (?P=그룹이름)이란 확장 구문을 사용하여야 합니다.
정규식에서 막 입문한 사람들이 가장 어려워지는 파트라고 합니다. 정규식 안에 확장 구문을 사용하면 순식간에 암호문처럼 보이기 때문에 전방탐색이 꼭 필요하다고 합니다.
import re
p = re.compile(".+:")
m = p.search("http://google.com")
print(m.group)
http:
위에선 정규식 ".+:"와 일치하는 문자열로 http: 를 돌려주었습니다.
만약 위 검색 결과에서 :을 제외하고 출력하는 방법이 있을까요?
복잡한 정규식에 그루핑이 불가하다는 조건까지 있다면 전방 탐색 방법을 사용해야 합니다.
전방 탐색에는 2종류가 있습니다.
정규식 | 종류 | 설명 |
---|---|---|
(?=...) | 긍정적 전방 탐색 | ...에 해당하는 정규식과 매치되어야 하며 조건이 통과되어도 문자열이 소비되지 않는다. |
(?!...) | 부정적 전방 탐색 | ...에 해당하는 정규식과 매치되지 않아야 하며 조건이 통과되어도 문자열이 소비되지 않는다. |
긍정적 전방 탐색을 사용하면 http:의 결과를 http로 바꿀 수 있습니다.
import re
p = re.compile(".+(?=:)")
m = p.search("http://google.com")
print(m.group())
http
(?=:)
기존에 사용했던 정규식 표현에 위의 코드가 추가되었고, 추가함으로써 기존 정규식 검색에서 :에 해당하는 문자열이 소비되지 않아 검색 결과에서 :를 제거해주는 효과를 보여줍니다.
책에서는 여러 종류의 긍정적 전방 탐색의 예를 보여줍니다. 몇 가지 보고 가겠습니다.
.[.].$
이 정규식은 '파일 이름 + . + 확장자'를 나타내는 정규식입니다. 이 정규식은 foo.bar, autoexec.bat, sendmail.cf 같은 형식의 파일과 매치될 수 있습니다.
위의 정규식에서 bat 파일을 제외하는 조건을 추가해봅시다. 한 번 안 보고 해보세요!
.[.][^b].$
네. 저는 제가 생각해서 적은 코드가 틀렸습니다. 위는 맞는 답이니 안심하십시오...
^는 not을 의미했고 b로 시작하는 파일을 거절한다고 적어두었네요. 근데 왜 굳이 이렇게... bat를 지우자 해놓고 b만 적으면 당연히 b로 시작하는 파일 확장자도 걸러내겠죠.
그럼 다른 방법을 사용해보겠습니다.
.*[.][^b]..|.[^a].|..[^t])$
이렇게 하면 또 무슨 문제가 생길까요? .cf 같은 확장자명이 두 글자인 경우에, 오류를 범할 수 있습니다.
그래서 최종적으로 바꾸고자 하는 방법은 바로바로...
.*[.]([^b]./?.?|.[^a]?.?|..?[^t]?)$
해당 정규식 표현이 마크다운 문법에 의해 생략되어서 (원인을 못찾음) 코드바 안에 넣었습니다.
확장자의 문자 개수가 2개여도 통과되는 정규식이라고 합니다. 치면서도 짜증이 날 정도로 답답...한 코드였네요.
이걸 활용할 수 있을까요? 점프투파이썬 스타일 상 뒤에 다른 쉬운 방법을 알려주길 바라며...
네. 바로 구원 투수가 이거였나 봅니다.🤣 부정형 전방 탐색 방법을 사용해 봅시다.
.*[.](?!bat$).*$
아까 앞보다 숨통이 트이는 쉬운 방법입니다. bat가 아닌 것은 통과 시키라는 의미입니다. 문자열이 소비되지 않으므로 bat가 아니라고 판단 시에 바로 정규식 매치가 진행됩니다.
exe도 bat와 같이 제외하라는 조건을 넣어봅시다.
.*[.](?!bat$|exe$).*$
입니다. 간단해졌네요.
이제 막바지를 달려가고 있습니다.
sub 메서드를 사용해 정규식과 매치되는 부분을 다른 문자로 쉽게 바꿀 수 있다고 합니다.
예시 코드를 보겠습니다.
# 문자열 바꾸기
import re
# sub 매서드
p = re.compile('(blue|white|red)')
print(p.sub('colour', 'blue socks and red shoes'))
colour socks and colour shoes
보니까 뒤의 문자열 blue, red가 colour로 변경되었는데요.
sub 메서드의 첫 번째 매개변수가 '바꿀 문자열(replacement)'가 된다고 합니다. 두 번째 매개변수는 '대상 문자열'입니다.
위의 경우와 다르게, 바꾸기 횟수를 제한하는 방법도 있습니다.
print(p.sub('colour', 'blue socks and red shoes', count = 1))
colour socks and red shoes
count를 1로 설정하니 앞의 blue만 colour로 대체되었습니다.
count를 지정해 줄 경우, 앞에서부터 카운트 한다는 것을 알 수 있습니다.
subn 역시 동일한 기능을 하지만 변환 결과를 튜플로 돌려주는 차이가 있습니다.
반환 해주는 튜플의 첫 번째 요소는 변경된 문자열이고, 두 번째 요소는 바꾸기가 발생한 횟수라고 합니다.
import re
# sub 매서드
p = re.compile('(blue|white|red)')
print(p.subn('colour', 'blue socks and red shoes'))
('colour socks and colour shoes', 2)
잘 이해가 되셨나요?
sub 메서드를 사용할 때 참조 구문을 사용할 수 있습니다. 참조 구문은 아까 앞에서 한 번 봤었는데요.
아래 예시는 '이름 + 전화번호' 를 '전화번호 + 이름'으로 바꿔주는 예라고 합니다.
sub의 바꿔줄 문자열 부분에 '\g<그룹이름>' 을 사용하면 정규식의 그룹 이름을 참조하게 됩니다.
import re
p = re.compile(r'(?P<name>\w+)\s+(?P<phone>(\d+)[-]\d+[-]\d+)')
print(p.sub('\g<phone> \g<name>', 'park 010-1234-1234'))
010-1234-1234 park
위처럼 사용하기 위해선
꽤 어려운 파트 같습니다...
print(p.sub('\g<2> \g<1>', 'park 010-1234-1234'))
010-1234-1234 park
아래처럼 그루핑의 인덱스를 사용해서 변경도 가능합니다.
import re
def hexrepl(match):
value = int(match.group())
return hex(value)
p = re.compile(r'\d+')
p.sub(hexrepl, 'Call 65490 for printing, 49152 for user code.')
Call 0xffd2 for printing, 0xc000 for user code.
hexrepl 함수는 match 객체를 입력으로 받아서 16진수로 변환하여 돌려주는 함수였습니다.
sub의 첫 번째 매개변수로 함수를 사용할 경우, 해당 함수의 첫 번째 매개변수에는 정규식이 매치된 match 객체가 입력되며, 매치되는 문자열이 함수의 반환값으로 바뀝니다.
진짜 마지막입니다! 미리 수고하셨습니다~
정규식에서 Greedy는 어떤 의미일까요?
import re
s = '<html><head><title>Title</title>'
print(len(s))
32
# Greedy
print(re.match('<./>', s).span())
print(re.match('<.*>', s).group())
(0,32)
(<html><head><title>Title</title>)
'<.*>' 정규식의 매치 결과로
<html>
문자열이 나올 것 같지만 * 란 메타 문자가 문자열을 모두 소비해버렸습니다.
어떻게하면 html 문자열까지만 소비하도록 막을 수 있을까요?
print(re.match('<.*?>', s).group())
<html>
?는 non-greedy 문자입니다. ?는
*?
+?
??
{m,n}?
와 같이 사용할 수 있습니다. 가능한 한 가장 최소한의 반복을 수행하도록 도와주는 역할을 합니다.
모든 문자열을 소비하겠다는 greedy 성격을 띄는 * 를 제지하는 역할이군요~
정규 표현식을 처음부터 끝까지 달려왔는데, 솔직히 제가 이해한 것은 50% 정도 되는 것 같고, 활용하려면 더 많은 예시 코드를 쳐보며 연습하거나 해석(?)하는 연습이 필요할 것 같습니다.
이렇게 방대한 분량의 학습일 지도 몰랐네요...
정규 표현식 파트에서 배운 것을 크게 분류해보자면
이렇게 되는 것 같습니다.
그럼 각자 복습 파이팅합시다.😉