Bạn viết một regex thay thế từ cat bằng dog trong văn bản, chạy xong mới phát hiện category biến thành dogegory và scatter thành sdogter. Đây là lỗi kinh điển, và cách chữa không phải sửa phần khớp ký tự — mà thêm những thứ không khớp ký tự nào: neo (anchor) và ranh giới (boundary). Chúng là nhóm cấu trúc regex đặc biệt: ^, $, \b, \B không tiêu thụ một ký tự nào của input (nên gọi là zero-width), mà chỉ khẳng định một điều kiện về vị trí hiện tại. Hiểu chúng là chìa khóa để regex khớp đúng chỗ chứ không chỉ đúng nội dung. Bài này (phần 3 loạt Regex) đo thật cách chúng đổi kết quả.

Zero-width: khẳng định vị trí, không nuốt ký tự

Nhớ lại phần 1: engine duyệt input theo máy trạng thái, mỗi ký tự đẩy nó qua một trạng thái. Anchor thì khác — tới lượt nó, engine không đọc ký tự nào; nó chỉ hỏi "vị trí hiện tại có thỏa điều kiện không?", đúng thì đi tiếp, sai thì nhánh này hỏng. Các anchor thường gặp:

  • ^: đầu chuỗi (hoặc đầu dòng nếu bật cờ MULTILINE).
  • $: cuối chuỗi (hoặc cuối dòng nếu bật MULTILINE).
  • \b: ranh giới từ — vị trí giữa một ký tự-từ (\w: chữ, số, _) và một ký tự-không-từ (\W), hoặc mép đầu/cuối.
  • \B: không phải ranh giới từ (ngược lại \b).

Vì zero-width, \bcat\b vẫn chỉ khớp đúng ba ký tự cat — hai \b không thêm ký tự nào vào kết quả.

Ảnh chụp đoạn mã nền tối minh hoạ neo và ranh giới trong regex khớp thứ không phải ký tự nào mũ đô la b hoa b zero-width re.MULTILINE vị trí không phải nội dung, anchor khớp vị trí không nuốt ký tự zero-width mũ đầu chuỗi hoặc đầu dòng nếu bật MULTILINE đô la cuối chuỗi hoặc cuối dòng b ranh giới từ giữa ký tự từ và không từ hoặc đầu cuối B hoa không phải ranh giới từ ngược lại b chúng không khớp ký tự nên zero-width chúng khẳng định một điều kiện tại vị trí hiện tại đúng sai rồi engine đi tiếp mà không nuốt gì, vì sao b quan trọng cat khớp cả trong category findall cat khớp mọi nơi kể cả giữa từ findall b cat b chỉ khớp từ cat độc lập thiếu b là nguồn bug kinh điển khi tìm thay thế từ, mũ đô la đổi nghĩa hoàn toàn với cờ MULTILINE findall mũ dong text mũ bằng đầu cả chuỗi một kết quả findall mũ dong text re.MULTILINE mũ bằng đầu mỗi dòng nhiều cùng mẫu cùng input chỉ khác một cờ kết quả khác hẳn, anchor còn giúp engine loại sớm re.compile mũ abc re.DEBUG opcode AT BEGINNING engine biết chỉ thử ở đầu chuỗi không dò tìm từng vị trí loại sớm nhanh hơn

Hình 1: Anchor (^ $ \b \B) khớp vị trí, không nuốt ký tự (zero-width); \b phân biệt cat độc lập với cat trong category; ^/$ đổi nghĩa theo cờ MULTILINE; và anchor ^ giúp engine loại sớm (opcode AT BEGINNING).

Đo thật: từng anchor đổi kết quả ra sao

Mình chạy python re trên các ca đối chiếu:

Ảnh chụp bảng kết quả chạy thật neo và ranh giới output thật go-lab python re, một ranh giới từ b cat khớp 4 chỗ b cat b chỉ 2 input cat scatter category cat chấm than không b findall cat bốn kết quả cat cat cat cat vị trí 0 5 12 21 có b findall b cat b hai kết quả cat cat vị trí 0 21 không b dính cả cat trong scatter và category có b chỉ từ độc lập B hoa cat B hoa cat giữa từ một kết quả cat chỉ cat trong scatter, hai neo mũ đô la đổi hẳn kết quả theo cờ re.MULTILINE input ba dòng dong mot xuống dòng dong hai xuống dòng dong ba mũ dong không M một kết quả dong mũ bằng đầu cả chuỗi mũ dong có M ba kết quả dong dong dong mũ bằng đầu mỗi dòng backslash w cộng đô la không M một kết quả ba đô la bằng cuối cả chuỗi w cộng đô la có M ba kết quả mot hai ba cuối mỗi dòng, ba anchor là zero-width và giúp engine loại sớm match b cat b group cat span 0 3 chỉ 3 ký tự b không nuốt re.compile mũ abc re.DEBUG AT AT_BEGINNING AST điều kiện ở đầu opcode AT BEGINNING engine chỉ thử ở đầu chuỗi LITERAL 0x61 a loại sớm không quét từng vị trí

Hình 2: Chạy thật — cat khớp 4 chỗ [0,5,12,21] (kể cả trong scatter, category), \bcat\b chỉ 2 [0,21], \Bcat\B đúng 1 (cat trong scatter); ^dong/\w+$ cho 1 kết quả khi không MULTILINE, 3 kết quả khi có; \bcat\b span (0,3); re.DEBUG của ^abc cho AT BEGINNING.

  • \b biến 4 match thành 2: trên cat scatter category cat!, mẫu trần cat khớp bốn vị trí [0, 5, 12, 21] — dính cả cat nằm trong scatter và category. Thêm ranh giới \bcat\b lọc còn hai [0, 21]: chỉ từ cat đứng một mình (và cat! vì ! là ký tự không-từ nên vẫn có ranh giới). Ngược lại \Bcat\B (yêu cầu cat nằm giữa một từ) khớp đúng một chỗ — cat trong scatter. Đây chính là cách sửa lỗi tìm-thay-thế dính nhầm.
  • ^ $ đổi nghĩa hoàn toàn với MULTILINE: trên chuỗi ba dòng, ^dong không cờ chỉ khớp dong ở đầu cả chuỗi (1 kết quả); bật re.MULTILINE thì ^ nghĩa là đầu mỗi dòng, khớp cả ba (['dong','dong','dong']). Tương tự \w+$: không cờ chỉ lấy từ cuối cả chuỗi (ba), có cờ lấy từ cuối mỗi dòng (mot, hai, ba). Cùng mẫu, cùng input — chỉ một cờ khác biệt là kết quả đổi hẳn.
  • Zero-width, và loại sớm: match \bcat\b có span=(0,3) — đúng ba ký tự, hai \b không nuốt gì. Và như phần 1, re.DEBUG của ^abc sinh opcode AT BEGINNING: engine biết chỉ cần thử ở đầu chuỗi, không phải dò từng vị trí — anchor ^ giúp loại sớm, tăng tốc.

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

\w và \b phụ thuộc định nghĩa "ký tự-từ", mà định nghĩa đó khác nhau theo engine/cờ. Mặc định \w là [A-Za-z0-9_] (ASCII), nên \b quanh chữ có dấu tiếng Việt hay ký tự Unicode có thể hành xử bất ngờ — "café" có thể bị coi là có ranh giới giữa caf và é. Python 3 mặc định bật Unicode cho \w, nhưng nhiều engine/cờ khác thì không. Khi làm việc với tiếng Việt (phần 9 về Unicode), phải kiểm tra \b có hiểu đúng chữ có dấu không, đừng cho là mặc định.

$ và ký tự xuống dòng cuối là một cái bẫy. Mặc định (không MULTILINE), $ khớp ở cuối chuỗi và ngay trước một \n kết thúc chuỗi. Điều này khiến \d+$ trên "123\n" vẫn khớp 123. Nếu cần khớp tuyệt đối cuối chuỗi không tính \n, dùng \Z (Python) / \z (nhiều engine). Nhầm $ với \Z gây bug tinh vi khi xử lý dữ liệu có dòng trắng cuối.

Anchor tăng tốc, nhưng chỉ khi đặt đúng. ^ giúp engine không dò mọi vị trí — nhưng nếu bạn viết .*abc (không neo) khi thật ra dữ liệu luôn bắt đầu bằng abc, engine vẫn thử tốn kém. Neo mẫu vào vị trí bạn biết chắc (dùng ^, hoặc \A cho đầu tuyệt đối) là một tối ưu regex đơn giản mà hiệu quả (phần 11). Ngược lại, đừng thêm ^/$ bừa nếu dữ liệu không thực sự neo ở đó — bạn sẽ khớp hụt.

Ba ý mang về

  1. Anchor là zero-width — khớp vị trí, không nuốt ký tự: đo thật \bcat\b có span=(0,3), chỉ ba ký tự dù có hai \b; chúng khẳng định điều kiện tại vị trí rồi engine đi tiếp.
  2. \b quyết định khớp từ hay khớp chuỗi con: đo thật cat dính 4 chỗ (kể cả trong scatter, category), \bcat\b lọc còn 2 từ độc lập — thiếu \b là nguồn lỗi tìm-thay-thế kinh điển.
  3. ^ $ đổi nghĩa theo MULTILINE: đo thật ^dong cho 1 kết quả (đầu cả chuỗi) hay 3 (đầu mỗi dòng) tùy cờ; nhớ $ còn khớp trước \n cuối (dùng \Z nếu cần cuối tuyệt đối), và \w/\b phụ thuộc định nghĩa Unicode của engine.

Nguồn

Phần sau ta đào vào hai viên gạch nền tảng nhất: lớp ký tự [...] và quantifier {m,n} — cách engine đọc một lớp, và vì sao {m,n} mở rộng thành đường đi khớp thế nào.