정규 표현식은 문자열을 _패턴 매칭_하는 매우 다재다능한 방법으로, 이를 위해 설계된 도메인 특화 언어(DSL)를 사용해요.
많은 정규식 기능이 Base R에 내장되어 있고, 오래된 코드에서 그런 예를 볼 수 있어요.
새로 작성하는 코드라면 stringr 라이브러리를 사용하는 것을 강력히 권장해요.
문자열 개념에서 언급했듯이, stringr은 문서가 잘 갖춰져 있고, 보통 패키지 작성자가 직접 작성해요.
이 R 강의에서는 이미 기본적인 정규식 문법에 익숙하다고 가정해요. R에 특화된 기능만 집중적으로 살펴볼게요.
정규 표현식 지식을 되살릴 수 있는 자료 몇 가지를 아래에 정리했어요.
Strings 개념에서 이미 다룬 함수 중 상당수는 기본적으로 정규식 검색을 위해 만들어졌어요.
문자열 개념에서는 문자열 리터럴에만 일치시키는 비교적 단순한 내용으로 다뤘지만, 대신 어떤 정규식 패턴이든 사용할 수 있어요.
엄밀히 말하면, R에서는 regex()를 호출해서 정규식을 만들어요. 편의를 위해 stringr 함수들은 어떤 패턴 문자열이든 정규식으로 취급해요. 아래 예시에서 첫 번째 str_replace_all()은 두 번째 버전을 더 간편하게 쓴 표현이에요:
input <- "Some input string"
# "\\s" matches any whitespace character
input |> str_replace_all("\\s", "_")
[1] "Some_input_string"
input |> str_replace_all(regex("\\s"), "_")
[1] "Some_input_string"
치트시트는 유용한 안내서예요. pattern 인자를 가지는 모든 함수(빨간색으로 표시)는 정규식을 받아들이고, 2쪽에는 정규식 문법이 요약되어 있어요.
대소문자 구분이나 로케일처럼 기본값을 바꿔야 할 때는 regex() 함수가 여전히 중요할 수 있다는 점에 유의해요.
# default is case sensitive
"A Mixed Assortment" |>
str_replace_all(regex("[aeiou]", ignore_case = TRUE), "*")
#> [1] "* M*x*d *ss*rtm*nt"
정규식 코드에는 위에서 사용한 \s처럼 백슬래시가 들어가는 경우가 많아요. 일반 R 문자열에서는 \를 두 번 써서 이스케이프해야 하므로 "\\s"가 돼요.
백슬래시가 많아지면 헷갈리기 쉬우니, 복잡한 경우에는 원시 문자열을 사용하는 게 도움이 될 수 있어요.
기본적으로 원시 문자열을 정의하는 문법은 r"( )"이지만, 다른 구분자도 사용할 수 있어요. 정규식 문법에서는 괄호 ( )와 대괄호 [ ]가 둘 다 흔히 쓰이므로(캡처 그룹과 옵션 그룹), 중괄호 { }가 좋은 선택이 될 수 있어요.
input |> str_replace_all(r"{\s\w}", "__")
#> [1] "Some__nput__tring"
몇 가지 질문과, 그 답을 찾는 데 도움이 되는 함수를 소개할게요.
일부 예시에서는 stringr에 연습용으로 포함된 80개의 과일 이름 벡터인 fruit를 사용해요.
문자열에 일치하는 부분이 있는지?
str_detect(), 또는 ^나 $ 앵커를 쓰지 않으려면 str_starts(), str_ends()를 사용해요.
# berries begining with b
fruit |> str_detect("^b.*berry") |> head(15)
#> [1] FALSE FALSE FALSE FALSE FALSE TRUE TRUE FALSE FALSE TRUE TRUE FALSE FALSE FALSE FALSE
# alternative syntax
fruit |> str_starts("b.*berry") |> head(15)
#> [1] FALSE FALSE FALSE FALSE FALSE TRUE TRUE FALSE FALSE TRUE TRUE FALSE FALSE FALSE FALSE
일치하는 문자열만 반환하려면?
문자열을 얻으려면 str_subset(), 인덱스를 얻으려면 str_which()를 사용해요.
fruit |> str_subset("a...e")
[1] "apple" "blackberry" "mandarine" "nectarine" "pineapple" "pomegranate" "raspberry" "salal berry"
> fruit |> str_which("a...e")
[1] 1 7 48 51 62 64 70 73
각 문자열에 일치하는 부분이 몇 개인지?
str_count()
# count vowels
fruit |> str_count("[aeiou]") |> head(10)
#> [1] 2 3 4 3 3 2 2 3 5 3
일치하는 위치는 어디인지?
대화형으로 쓸 때는 str_view(), 더 일반적으로는 str_locate()나 str_locate_all()을 사용해요.
# str_view only shows matching elements, with match in < > delimiters
fruit |> str_view("a...e")
[1] │ <apple>
[7] │ bl<ackbe>rry
[48] │ mand<arine>
[51] │ nect<arine>
[62] │ pine<apple>
[64] │ pomegr<anate>
[70] │ r<aspbe>rry
[73] │ sal<al be>rry
# str_locate returns all elements
fruit |> str_locate("a...e") |> head(10)
start end
[1,] 1 5
[2,] NA NA
[3,] NA NA
[4,] NA NA
[5,] NA NA
[6,] NA NA
[7,] 3 7
[8,] NA NA
[9,] NA NA
[10,] NA NA
흔히 무엇이 일치하는지 알고 싶을 때가 있어요. 이는 정규식 안에 괄호로 캡처 그룹을 넣으면 해결돼요.
같은 정규식 안에서 \1, \2 등을 사용해 캡처를 다시 참조할 수 있어요.
예를 들어 반복되는 두 글자 쌍을 찾는 경우는 이렇게 해요:
fruit |> str_subset("(..)\\1")
[1] "banana" "coconut" "cucumber" "jujube" "papaya" "salal berry"
비슷한 문법으로, 캡처를 대체 문자열에서 사용할 수도 있어요:
# double to triple
fruit |>
str_subset("(..)\\1") |>
str_replace("(..)\\1", "\\1\\1\\1")
[1] "bananana" "cococonut" "cucucumber" "jujujube" "papapaya" "salalal berry"
나중에 처리할 일치 결과를 모으려면 첫 번째 일치만 필요할 때는 str_match() 함수를, 모든 일치가 필요할 때는 str_match_all()을 사용해요.
캡처 그룹은 어떤 언어에서든 복잡한 출력을 만들어내는 경향이 있어요.
R의 방식(덤으로 벡터화까지 되어 있다는 점이 추가 난관이에요)은 str_match()로 행렬을 만드는 것으로, 입력 문자열마다 행이 하나씩 생기고, 1번 열에는 일치한 전체 문자열이, 나머지 열에는 각 캡처 그룹이 들어가요.
이름 있는 그룹을 사용하면 결과 행렬의 열 이름이 되기 때문에 유리할 수 있어요.
recipe <- "dissolve 25g sugar in 200ml water"
recipe |> str_match("(\\d+g) .* (\\d+ml)")
[,1] [,2] [,3]
[1,] "25g sugar in 200ml" "25g" "200ml"
# use named capture groups
recipe |> str_match("(?<wt>\\d+g) .* (?<vol>\\d+ml)")
wt vol
[1,] "25g sugar in 200ml" "25g" "200ml"
이 강의에서는 행렬을 많이 다루지 않았지만, 참고할 수 있는 개념 문서가 있어요. 행렬은 오랫동안 Base R의 일부였음에도 Tidyverse 생태계와는 잘 맞지 않아요.
간단히 요약하면, 행렬은 인덱스가 두 개인 벡터처럼 다루면 되고, 순서는 [rows, cols]예요.
두 인덱스 중 하나를 비워 두면 행이나 열 전체를 얻을 수 있어요.
m[r,]로 행 r 하나를, 또는 m[, c]로 열 c 하나를 가져올 때, 반환되는 값은 기본적으로 벡터로 펼쳐져요.
> amounts <- recipe |> str_match("(?<wt>\\d+g) .* (?<vol>\\d+ml)")
# get all of row 1, as a named vector
> amt1 <- amounts[1,]
> amt1
wt vol
"25g sugar in 200ml" "25g" "200ml"
# get a vector element
amt1["wt"] |> unname()
[1] "25g"
불가피하게도, str_match_all()은 구조가 훨씬 더 복잡한 출력을 만들어요:
[[1]]로 인덱싱해서 첫 번째 원소를 꺼내면 돼요.match()와 같아요.mix <- "Mix 200g of sugar, 180g of flour and 150g of butter"
# raw output
> str_match_all(mix, "(\\d+)g")
[[1]]
[,1] [,2]
[1,] "200g" "200"
[2,] "180g" "180"
[3,] "150g" "150"
# we just want the first list element
> str_match_all(mix, "(\\d+)g")[[1]]
[,1] [,2]
[1,] "200g" "200"
[2,] "180g" "180"
[3,] "150g" "150"
# captures are in column 2, so get that as a vector
> str_match_all(mix, "(\\d+)g")[[1]][,2]
[1] "200" "180" "150"
물론 일치에 실패할 수도 있어요.
그럴 때 결과는 특별한 값인 NA가 돼요.
없음 개념에서 설명한 대로 이 경우를 검사할 준비를 해 두세요.
# failed match
recipe |> str_match("(not here)")
[,1] [,2]
[1,] NA NA
R for Data science에서 지적하기를:
str_match()는 행렬을 반환해서 다루기가 그리 편하지는 않아요.
각주를 달아서:
이 책에서는 행렬을 아예 다루지 않기 때문이죠!
이 책의 저자 중 한 명은 stringr 라이브러리도 만들었기 때문에, 사실상 자신의 작업을 비판하고 있는 셈이에요.
이 함수의 구현이 앞으로 나올 릴리스에서 어떻게 바뀔지 지켜보는 것도 흥미로울 거예요.