Nếu regex là một ngôn ngữ, thì lớp ký tự ([...]) và quantifier (*, +, {m,n}) là bảng chữ cái và ngữ pháp cơ bản của nó — gần như mọi mẫu đều dựng từ hai viên gạch này. Chúng đơn giản đến mức dễ bị coi thường, nhưng chính sự "hiển nhiên" đó giấu vài cái bẫy khiến mẫu khớp sai một cách âm thầm: \w không giống [a-zA-Z] như nhiều người tưởng, và {2,4} khớp không như bạn đoán. Bài này (phần 4 loạt Regex) đo thật cả hai để bạn dùng chúng chính xác — và nối tiếp phần 1, ta xem luôn chúng biên dịch thành opcode gì.

Lớp ký tự: khớp đúng một ký tự trong tập

Một lớp ký tự [...] khớp đúng một ký tự nằm trong tập bạn liệt kê:

  • [abc] — một trong a, b, c.
  • [a-z] — một chữ thường, dùng dải (range) từ a tới z.
  • [^0-9] — một ký tự không phải số (dấu ^ ngay sau [ nghĩa là phủ định).

Có các shorthand (lối tắt) cho những lớp hay dùng: \d = [0-9], \w = "ký tự-từ" (chữ + số + _), \s = khoảng trắng; và bản in hoa \D, \W, \S là phủ định tương ứng. Nhưng "chữ" trong \w là gì thì tùy engine — và đó là cái bẫy.

Ảnh chụp đoạn mã nền tối minh hoạ lớp ký tự và quantifier hai viên gạch nền của regex, lớp ký tự một tập hợp engine khớp đúng một ký tự trong tập abc một trong a b c a-z một chữ thường dùng dải a tới z mũ 0-9 một ký tự không phải số mũ đầu ngoặc vuông là phủ định shorthand là lối tắt cho lớp hay dùng d bằng 0-9 w bằng chữ số gạch dưới s bằng khoảng trắng D W S hoa là phủ định của chúng, cạm bẫy w khác a-zA-Z với chữ có dấu trên Hoà w khớp cả à Unicode a-zA-Z thì không findall w cộng Hoà nguyên vẹn findall a-zA-Z cộng Ho đứt ở à rất quan trọng với tiếng Việt chi tiết ở phần 9 Unicode, quantifier lặp lớp nhóm trước nó bao nhiêu lần sao 0 cộng cộng 1 cộng hỏi 0 hoặc 1 m đúng m lần m phẩy n từ m đến n lần greedy ưu tiên nhiều nhất trước m phẩy m lần trở lên thêm hỏi sau quantifier là lazy m phẩy n hỏi ưu tiên ít nhất, xem opcode a-z 2 phẩy 4 biên dịch ra gì re.DEBUG MAX_REPEAT 2 4 IN RANGE 97 122 lặp 2 tới 4 lần một ký tự a-z

Hình 1: Lớp ký tự [...] khớp đúng một ký tự trong tập ([a-z], [^0-9]); shorthand \d/\w/\s và phủ định \D/\W/\S; quantifier */+/?/{m,n} lặp phần trước; và \w khác [a-zA-Z] với chữ có dấu.

Đo thật: từng lớp và quantifier khớp gì

Mình chạy python re trên abc_123 Hoà-9 cafe33:

Ảnh chụp bảng kết quả chạy thật lớp ký tự và quantifier output thật go-lab python re input abc gạch dưới 123 Hoà gạch 9 cafe33, một lớp ký tự vs shorthand w bắt cả gạch dưới số và à Unicode a-z cộng ba kết quả abc o cafe chỉ chữ thường a-z w cộng bốn kết quả abc gạch dưới 123 Hoà 9 cafe33 chữ số gạch dưới cả à a-zA-Z cộng abc Ho cafe đứt ở à thành Ho d cộng 123 9 33 mũ d space cộng abc gạch dưới Hoà gạch cafe không số không space, hai phủ định D W S đảo lại của d w s D cộng không số abc gạch dưới space Hoà gạch space cafe W cộng không từ space gạch space S cộng không trắng abc gạch dưới 123 Hoà gạch 9 cafe33, ba quantifier trên 1234567 min-max và greedy lazy d 3 dấu ngoặc 123 456 đúng 3 bỏ lại 7 d 2 phẩy 4 1234 567 greedy lấy 4 trước rồi 3 d 2 phẩy 1234567 hai trở lên nuốt hết d 2 phẩy 4 hỏi 12 34 56 lazy lấy 2 mỗi lần, bốn opcode của a-z 2 phẩy 4 re.DEBUG MAX_REPEAT 2 4 IN RANGE 97 122 a tới z REPEAT_ONE 10 2 4 lặp 2 tới 4 lần RANGE 0x61 0x7a SUCCESS

Hình 2: Chạy thật — [a-z]+ cho ['abc','o','cafe'] nhưng \w+ cho ['abc_123','Hoà','9','cafe33'] (bắt cả à), còn [a-zA-Z]+ đứt ở à thành 'Ho'; quantifier trên 1234567: \d{3}→['123','456'], \d{2,4}→['1234','567'] (greedy lấy 4), \d{2,}→['1234567'], \d{2,4}?→['12','34','56'] (lazy).

  • \w KHÁC [a-zA-Z] — cái bẫy Unicode: đây là điểm quan trọng nhất. Trên Hoà, \w+ khớp cả cụm Hoà vì trong Python 3, \w mặc định hiểu Unicode và coi à là ký tự-từ. Nhưng [a-zA-Z]+ chỉ liệt kê ASCII, nên nó đứt ở à, cho ra 'Ho'. Nếu bạn viết [a-zA-Z]+ để "lấy một từ" trên dữ liệu tiếng Việt, bạn sẽ cắt cụt mọi từ có dấu mà không có lỗi nào báo. \w cũng bắt _ và chữ số, nên abc_123 là một match với \w+ nhưng [a-z]+ chỉ lấy abc.
  • Phủ định là đảo hoàn toàn: \D+ (không phải số) cho ['abc_', ' Hoà-', ' cafe'] — mọi đoạn không có chữ số. \W+ (không phải ký tự-từ) chỉ lấy khoảng trắng và -. \S+ (không phải khoảng trắng) chia chuỗi theo space thành ba "token".
  • {m,n} là greedy, {m,n}? là lazy: trên 1234567, \d{3} lấy đúng ba số mỗi lần (123, 456, bỏ lại 7 vì không đủ ba). \d{2,4} (greedy) ưu tiên nhiều nhất: lấy 1234 (4 số) rồi 567 (3 số còn lại). \d{2,} lấy hai trở lên nên nuốt cả 1234567. Còn \d{2,4}? (lazy, thêm ?) ưu tiên ít nhất: lấy đúng 2 mỗi lần → 12, 34, 56. Đây chính là greedy/lazy của phần 2, áp lên quantifier có cận.
  • Opcode xác nhận cơ chế: re.DEBUG của [a-z]{2,4} sinh MAX_REPEAT 2 4 bọc quanh IN / RANGE (97,122) — nghĩa là "lặp 2 tới 4 lần một ký tự trong dải a-z". Cỗ máy trạng thái ở phần 1 hiện ra đúng như mô tả.

Đánh đổi cần cân nhắc

Trong lớp ký tự, hầu hết metachar mất nghĩa đặc biệt — nhưng không phải tất cả. Bên trong [...], dấu . chỉ là dấu chấm, * chỉ là sao — không cần escape. Nhưng bốn ký tự vẫn đặc biệt: ] (đóng lớp), \ (escape), ^ (phủ định nếu ở đầu), và - (dải nếu ở giữa). Muốn khớp dấu - theo nghĩa đen, đặt nó ở đầu/cuối lớp ([-a-z]) hoặc escape. Nhầm chỗ này tạo ra dải ngoài ý muốn, ví dụ [a-z-0-9] không như bạn nghĩ.

\d cũng có thể là Unicode — không chỉ [0-9]. Giống \w, \d trong Python 3 mặc định khớp cả chữ số của các hệ chữ khác (chữ số Ả Rập-Ấn, Devanagari...), không chỉ 0-9 ASCII. Nếu bạn chỉ muốn 0-9, hãy viết [0-9] tường minh, hoặc bật cờ re.ASCII. Với dữ liệu quốc tế, "\d là số" là một giả định nguy hiểm — điều ta sẽ đào kỹ ở phần 9.

{m,n} với n lớn có thể mở rộng thành máy trạng thái khổng lồ. Một số engine "unroll" (trải phẳng) a{1,1000} thành cả nghìn trạng thái, tốn bộ nhớ biên dịch và có thể góp phần vào backtracking tệ. RE2 giới hạn kích thước mẫu để chặn điều này; engine backtracking thì không. Khi cần lặp số lượng lớn, cân nhắc +/* (nếu không cần cận chính xác) thay vì {0,10000}.

Ba ý mang về

  1. \w không phải [a-zA-Z] — bẫy Unicode: đo thật trên Hoà, \w+ khớp cả à (được cả Hoà) còn [a-zA-Z]+ đứt thành 'Ho'; dùng [a-zA-Z] cho dữ liệu tiếng Việt sẽ cắt cụt từ có dấu mà không báo lỗi.
  2. Quantifier {m,n} là greedy, thêm ? thành lazy: đo thật trên 1234567, \d{2,4} lấy ['1234','567'] (ưu tiên 4), \d{2,4}? lấy ['12','34','56'] (ưu tiên 2); \d{3} lấy đúng 3, \d{2,} nuốt hết.
  3. Lớp và quantifier biên dịch thẳng thành opcode: re.DEBUG cho [a-z]{2,4} sinh MAX_REPEAT 2 4 + IN RANGE (97,122); nhớ trong [...] chỉ ] \ ^ - còn đặc biệt, và \d/\w mặc định Unicode (dùng [0-9]/re.ASCII nếu chỉ muốn ASCII).

Nguồn

Phần sau ta chạm tới thứ khiến regex "không còn regular" theo nghĩa lý thuyết: backreference \1 — vì sao khả năng "khớp lại đúng cái đã khớp" buộc engine phải backtracking và không thể dùng DFA tuyến tính.