Giáo trình: Regular Expression (Biểu thức chính quy)

Chào mừng bạn đến với giáo trình chi tiết về Regular Expression (Regex hoặc RegEx). Đây là một công cụ cực kỳ mạnh mẽ và linh hoạt được sử dụng để tìm kiếm, khớp, và thao tác chuỗi văn bản dựa trên các mẫu nhất định. Dù bạn là lập trình viên, nhà phân tích dữ liệu, hay chỉ đơn giản là người muốn xử lý văn bản hiệu quả hơn, việc nắm vững Regular Expression sẽ là một kỹ năng vô cùng giá trị.

Trong giáo trình này, chúng ta sẽ đi từ những khái niệm cơ bản nhất đến các kỹ thuật nâng cao, kèm theo nhiều ví dụ minh họa cụ thể để giúp bạn dễ dàng nắm bắt.

---

1. Giới thiệu về Regular Expression

Regular Expression là một chuỗi ký tự tạo thành một mẫu tìm kiếm. Khi bạn tìm kiếm dữ liệu, bạn có thể sử dụng mẫu này để mô tả những gì bạn đang tìm kiếm. Regular Expression không phải là một ngôn ngữ lập trình riêng biệt, mà là một tiêu chuẩn được tích hợp vào hầu hết các ngôn ngữ lập trình hiện đại (như Python, JavaScript, Java, PHP, C#, Ruby, Perl, Go, v.v.) và các công cụ xử lý văn bản (như grep, sed, awk trong Linux).

1.1. Ứng dụng của Regular Expression

  • Kiểm tra tính hợp lệ của dữ liệu (Validation): Kiểm tra xem địa chỉ email, số điện thoại, mật khẩu, URL có đúng định dạng hay không.
  • Tìm kiếm và thay thế (Search and Replace): Tìm kiếm tất cả các từ hoặc cụm từ theo một mẫu nhất định và thay thế chúng bằng nội dung khác.
  • Trích xuất dữ liệu (Extraction): Lấy ra các phần cụ thể của chuỗi, ví dụ như số điện thoại từ một đoạn văn bản.
  • Phân tích cú pháp (Parsing): Phân tích các tệp log, cấu hình, hoặc dữ liệu có cấu trúc.
  • Thao tác chuỗi phức tạp: Đổi định dạng ngày tháng, sắp xếp lại các phần của chuỗi.
---

2. Các ký tự cơ bản (Literals và Metacharacters)

Một Regular Expression được xây dựng từ hai loại ký tự chính:

  • Ký tự nguyên bản (Literal Characters): Là các ký tự thông thường khớp chính xác với chính nó. Ví dụ: a khớp với a, 123 khớp với 123.
  • Ký tự đặc biệt (Metacharacters): Là các ký tự có ý nghĩa đặc biệt, được sử dụng để định nghĩa các mẫu phức tạp hơn.

Dưới đây là các metacharacter quan trọng nhất:

2.1. Metacharacters

Ký tự Mô tả Ví dụ Khớp với
. (dấu chấm) Khớp với bất kỳ ký tự đơn nào (trừ ký tự xuống dòng \n). c.t cat, cot, cbt, v.v.
\ (dấu gạch ngược) Ký tự thoát (escape character). Dùng để biến một metacharacter thành ký tự nguyên bản, hoặc biến một ký tự nguyên bản thành ký tự đặc biệt. \. Khớp với dấu chấm .
^ (dấu mũ) Khớp với vị trí bắt đầu của chuỗi. ^Hello Chuỗi bắt đầu bằng Hello như Hello World
$ (dấu đô la) Khớp với vị trí kết thúc của chuỗi. World$ Chuỗi kết thúc bằng World như Hello World
* (dấu sao) Khớp với 0 hoặc nhiều lần của ký tự/nhóm ký tự đứng trước nó. a*b b, ab, aab, aaab
+ (dấu cộng) Khớp với 1 hoặc nhiều lần của ký tự/nhóm ký tự đứng trước nó. a+b ab, aab, aaab (nhưng không khớp b)
? (dấu hỏi) Khớp với 0 hoặc 1 lần của ký tự/nhóm ký tự đứng trước nó (tức là tùy chọn). colou?r color, colour
{n} Khớp chính xác n lần của ký tự/nhóm ký tự đứng trước nó. a{3} aaa
{n,} Khớp ít nhất n lần của ký tự/nhóm ký tự đứng trước nó. a{2,} aa, aaa, aaaa, v.v.
{n,m} Khớp từ n đến m lần của ký tự/nhóm ký tự đứng trước nó. a{2,4} aa, aaa, aaaa
[] (dấu ngoặc vuông) Định nghĩa một lớp ký tự (character class). Khớp với bất kỳ ký tự đơn nào trong ngoặc. [aeiou] Một nguyên âm (a, e, i, o, u)
- (dấu gạch nối) Trong [], biểu thị một phạm vi ký tự. [0-9] Một chữ số từ 0 đến 9
^ (trong []) Trong [], biểu thị phủ định (negation). Khớp với bất kỳ ký tự nào KHÔNG nằm trong ngoặc. [^0-9] Một ký tự không phải là chữ số
| (dấu gạch đứng) Toán tử OR (hoặc). Khớp với biểu thức bên trái HOẶC bên phải. cat|dog cat hoặc dog
() (dấu ngoặc tròn) Tạo thành một nhóm (group). Dùng để áp dụng lượng từ (quantifiers) cho một nhóm hoặc để ghi nhớ (capturing) một phần của khớp. (ab)+ ab, abab, ababab

Ví dụ cơ bản:



                // Kiểm tra xem chuỗi có chứa "hello" không

                Regex: `hello`

                Input: "hello world"  -> Khớp

                Input: "hi there"     -> Không khớp

                // Khớp với một số điện thoại đơn giản (ví dụ: 123-456-7890)

                Regex: `\d{3}-\d{3}-\d{4}`

                Input: "My phone is 123-456-7890." -> Khớp "123-456-7890"

            
---

3. Lớp ký tự định sẵn (Predefined Character Classes)

Để thuận tiện, Regular Expression cung cấp các lớp ký tự định sẵn cho các mẫu phổ biến:

Ký tự Mô tả Tương đương với Ví dụ
\d Khớp với bất kỳ chữ số nào (digit). [0-9] \d{2} khớp với 12, 99
\D Khớp với bất kỳ ký tự nào KHÔNG phải chữ số. [^0-9] \D khớp với a, $
\w Khớp với bất kỳ ký tự chữ, số hoặc dấu gạch dưới (word character). [a-zA-Z0-9_] \w+ khớp với hello_world123
\W Khớp với bất kỳ ký tự nào KHÔNG phải chữ, số hoặc dấu gạch dưới. [^a-zA-Z0-9_] \W khớp với !, @, (khoảng trắng)
\s Khớp với bất kỳ ký tự khoảng trắng nào (whitespace character): dấu cách, tab, xuống dòng, v.v. [\t\n\r\f\v ] hello\sworld khớp với hello world
\S Khớp với bất kỳ ký tự nào KHÔNG phải khoảng trắng. [^\t\n\r\f\v ] \S+ khớp với một từ không có khoảng trắng
\b Khớp với ranh giới từ (word boundary). Vị trí giữa một ký tự chữ/số và một ký tự không phải chữ/số (hoặc đầu/cuối chuỗi). \bcat\b khớp với cat trong the cat nhưng không khớp catamaran
\B Khớp với vị trí KHÔNG phải ranh giới từ. \Bcat\B khớp với cat trong wildcat

Ví dụ về lớp ký tự định sẵn:



                // Tìm kiếm tất cả các số trong một chuỗi

                Regex: `\d+`

                Input: "There are 123 apples and 45 oranges."

                Matches: "123", "45"

                // Tìm kiếm các từ riêng biệt

                Regex: `\bhello\b`

                Input: "hello, world! The word 'hello' is here."

                Matches: "hello" (chỉ từ "hello" riêng biệt)

            
---

4. Lượng từ (Quantifiers)

Lượng từ cho phép bạn chỉ định số lần một ký tự, lớp ký tự hoặc nhóm xuất hiện.

Lượng từ Mô tả Ví dụ Khớp với
* 0 hoặc nhiều lần a* "", "a", "aa", "aaa"
+ 1 hoặc nhiều lần a+ "a", "aa", "aaa"
? 0 hoặc 1 lần (tùy chọn) a? "", "a"
{n} Chính xác n lần a{3} "aaa"
{n,} Ít nhất n lần a{2,} "aa", "aaa", ...
{n,m} Từ n đến m lần a{2,4} "aa", "aaa", "aaaa"

4.1. Lượng từ Tham lam (Greedy) vs. Không tham lam (Lazy)

Mặc định, các lượng từ (*, +, ?, {n,}, {n,m}) là tham lam (greedy). Điều này có nghĩa là chúng sẽ khớp với chuỗi dài nhất có thể mà vẫn cho phép toàn bộ biểu thức khớp.

Để biến chúng thành không tham lam (lazy), bạn thêm dấu ? sau lượng từ.

Lượng từ Lazy Mô tả Ví dụ Khớp với
*? 0 hoặc nhiều lần (không tham lam) <.*?> Trong <b>bold</b>, khớp <b></b> riêng biệt (thay vì <b>bold</b>)
+? 1 hoặc nhiều lần (không tham lam) Tương tự như trên nhưng yêu cầu ít nhất 1 ký tự
?? 0 hoặc 1 lần (không tham lam)
{n,}? Ít nhất n lần (không tham lam)
{n,m}? Từ n đến m lần (không tham lam)

Ví dụ về Greedy vs. Lazy:



                Input string: "text1 and text2"

                // Greedy (mặc định)

                Regex: `<.*>`

                Match: "text1 and text2" (khớp toàn bộ từ dấu < đầu tiên đến dấu > cuối cùng)

                // Lazy

                Regex: `<.*?>`

                Matches: "", "", "", "" (khớp từng cặp tag riêng biệt)

            
---

5. Nhóm (Groups) và Ghi nhớ (Capturing)

Dấu ngoặc tròn () được sử dụng để:

  • Nhóm các biểu thức con: Cho phép áp dụng lượng từ cho một nhóm các ký tự.
  • Ghi nhớ các khớp con (Capturing Groups): Lưu trữ phần văn bản được khớp bởi nhóm để có thể truy cập sau này.

5.1. Nhóm không ghi nhớ (Non-Capturing Groups)

Nếu bạn chỉ muốn nhóm các ký tự mà không muốn ghi nhớ phần khớp đó, bạn sử dụng (?:...).

Ký tự Mô tả Ví dụ
(...) Tạo một nhóm ghi nhớ. Nội dung khớp bởi nhóm này có thể được truy cập bằng số thứ tự (ví dụ: \1, \2 trong biểu thức hoặc qua API của ngôn ngữ). (\d{3})-(\d{3})-(\d{4}): Khớp số điện thoại và ghi nhớ 3 phần.
(?:...) Tạo một nhóm không ghi nhớ. Dùng để nhóm mà không tạo captured group mới, tối ưu hiệu suất hơn. (?:red|blue) car: Khớp "red car" hoặc "blue car" nhưng không ghi nhớ "red" hay "blue".

Ví dụ về Capturing Groups:



                // Trích xuất tên miền từ URL

                Regex: `https?:\/\/(www\.)?([a-zA-Z0-9.-]+)\.([a-zA-Z]{2,6})`

                Input: "https://www.example.com"

                Matches:

                Group 1: "www." (tùy chọn)

                Group 2: "example"

                Group 3: "com"

                // Sử dụng backreference để tìm các từ lặp lại

                Regex: `\b(\w+)\s+\1\b`

                Input: "This is a test test string."

                Match: "test test" (Group 1 là "test")

            

5.2. Tham chiếu ngược (Backreferences)

Bạn có thể tham chiếu lại nội dung đã được khớp bởi một capturing group bằng cách sử dụng \n (trong đó n là số thứ tự của nhóm). Điều này rất hữu ích để tìm các mẫu lặp lại.

---

6. Khẳng định vị trí (Assertions)

Assertions không tiêu thụ ký tự mà chỉ kiểm tra vị trí hiện tại trong chuỗi có đáp ứng điều kiện nào đó không.

Assertion Mô tả Ví dụ
^ Bắt đầu chuỗi/dòng. ^abc
$ Kết thúc chuỗi/dòng. abc$
\b Ranh giới từ. \bword\b
\B Không phải ranh giới từ. \Bword\B
(?=...) Lookahead tích cực (Positive Lookahead): Khớp nếu theo sau là mẫu ..., nhưng không bao gồm mẫu đó trong khớp. foo(?=bar) khớp với "foo" chỉ khi nó theo sau là "bar" (trong "foobar").
(?!...) Lookahead tiêu cực (Negative Lookahead): Khớp nếu KHÔNG theo sau là mẫu .... foo(?!bar) khớp với "foo" chỉ khi nó KHÔNG theo sau là "bar" (trong "foo_baz").
(?<=...) Lookbehind tích cực (Positive Lookbehind): Khớp nếu được đứng trước bởi mẫu .... (?<=bar)foo khớp với "foo" chỉ khi nó được đứng trước bởi "bar" (trong "barfoo").
(?<!...) Lookbehind tiêu cực (Negative Lookbehind): Khớp nếu KHÔNG được đứng trước bởi mẫu .... (?<!bar)foo khớp với "foo" chỉ khi nó KHÔNG được đứng trước bởi "bar" (trong "bazfoo").
Lưu ý về Lookbehind: Một số ngôn ngữ (như JavaScript trước ES2018) không hỗ trợ lookbehind hoặc chỉ hỗ trợ lookbehind với độ dài cố định. Luôn kiểm tra tài liệu của ngôn ngữ bạn đang sử dụng.

Ví dụ về Assertions:



                // Tìm các số chỉ khi chúng đứng trước dấu đô la

                Regex: `\d+(?=\$)`

                Input: "Price: 100$ and 50 EUR."

                Match: "100"

                // Tìm các từ "cat" không phải là một phần của "catfish"

                Regex: `cat(?!\w)`

                Input: "The cat and the catfish."

                Match: "cat" (chỉ từ "cat" đầu tiên)

                // Tìm các số có chữ "USD" đứng trước

                Regex: `(?<=USD)\s*\d+`

                Input: "The cost is USD 150."

                Match: " 150"

            
---

7. Cờ (Flags/Modifiers)

Các cờ là các tùy chọn thay đổi cách Regular Expression hoạt động. Chúng thường được thêm vào sau biểu thức chính quy (tùy thuộc vào ngôn ngữ).

Cờ Tên đầy đủ Mô tả Ví dụ (trong JS)
i Case-insensitive Khớp không phân biệt chữ hoa/thường. /apple/i khớp với "Apple", "apple", "APPLE".
g Global Tìm tất cả các khớp, không chỉ khớp đầu tiên. "cat dog cat".match(/cat/g) trả về ["cat", "cat"].
m Multiline Cho phép ^$ khớp với đầu/cuối mỗi dòng thay vì toàn bộ chuỗi. Với "line1\nline2", /^line/gm khớp cả "line1" và "line2".
s Dotall / Singleline Cho phép . khớp cả ký tự xuống dòng (\n). /a.b/s khớp với "a\nb".
u Unicode Xử lý các ký tự Unicode đúng cách (ví dụ: các cặp surrogate, các ký tự không thuộc bảng mã ASCII). /\p{L}/u (trong một số ngôn ngữ) khớp với bất kỳ ký tự chữ cái Unicode nào.
y Sticky (Chỉ JavaScript) Khớp từ vị trí cuối cùng của khớp trước đó trong chuỗi.

Ví dụ về Cờ:



                // Tìm tất cả các số, không phân biệt chữ hoa/thường (không áp dụng cho số)

                // Ví dụ này để minh họa `i` trên chữ cái

                Regex: `/hello/gi`

                Input: "Hello World, hello again."

                Matches: "Hello", "hello"

                // Tìm các dòng bắt đầu bằng "Error" trong văn bản nhiều dòng

                Regex: `/^Error/gm`

                Input: "Line 1\nError: something went wrong\nLine 3"

                Match: "Error"

            
---

8. Regex trong các Ngôn ngữ lập trình phổ biến

Mặc dù cú pháp Regex là tiêu chuẩn, cách bạn sử dụng chúng trong các ngôn ngữ lập trình có thể khác nhau. Dưới đây là một số ví dụ điển hình:

8.1. JavaScript

Sử dụng đối tượng RegExp hoặc cú pháp literal /.../.



            // Khởi tạo Regex

            const regex1 = /apple/i; // Literal syntax, case-insensitive

            const regex2 = new RegExp('banana', 'g'); // Constructor syntax, global

            // Kiểm tra khớp

            console.log(regex1.test("Apple")); // true

            // Tìm tất cả các khớp

            const str = "The quick brown fox jumps over the lazy dog. dog.";

            const matches = str.match(/dog/g);

            console.log(matches); // ["dog", "dog"]

            // Thay thế

            const newStr = str.replace(/dog/g, "cat");

            console.log(newStr); // "The quick brown fox jumps over the lazy cat. cat."

            // Trích xuất nhóm

            const url = "https://www.example.com/path";

            const urlRegex = /(https?):\/\/(www\.)?([^/]+)/;

            const urlMatch = url.match(urlRegex);

            console.log(urlMatch[1]); // "https" (protocol)

            console.log(urlMatch[3]); // "example.com" (hostname)

        

8.2. Python

Sử dụng module re.



            import re

            # Kiểm tra khớp

            if re.search(r"apple", "I like apples", re.IGNORECASE):

                print("Found apple!")

            # Tìm tất cả các khớp

            text = "Colors: red, blue, green, red again."

            all_reds = re.findall(r"red", text)

            print(all_reds) # ['red', 'red']

            # Thay thế

            new_text = re.sub(r"red", "yellow", text)

            print(new_text) # "Colors: yellow, blue, green, yellow again."

            # Trích xuất nhóm

            phone_number = "My phone is 123-456-7890."

            match = re.search(r"(\d{3})-(\d{3})-(\d{4})", phone_number)

            if match:

                print(match.group(0)) # "123-456-7890" (toàn bộ khớp)

                print(match.group(1)) # "123"

                print(match.group(2)) # "456"

                print(match.group(3)) # "7890"

        

8.3. Java

Sử dụng các lớp PatternMatcher trong gói java.util.regex.



            import java.util.regex.Matcher;

            import java.util.regex.Pattern;

            public class RegexExample {

                public static void main(String[] args) {

                    // Kiểm tra khớp

                    String text = "Hello World";

                    boolean found = Pattern.matches("Hello", text);

                    System.out.println(found); // true

                    // Tìm tất cả các khớp

                    Pattern pattern = Pattern.compile("cat", Pattern.CASE_INSENSITIVE);

                    Matcher matcher = pattern.matcher("Cat, dog, CAT, mouse.");

                    while (matcher.find()) {

                        System.out.println("Found: " + matcher.group() + " at " + matcher.start() + "-" + matcher.end());

                    }

                    // Output:

                    // Found: Cat at 0-3

                    // Found: CAT at 9-12

                    // Thay thế

                    String replacedText = text.replaceAll("World", "Java");

                    System.out.println(replacedText); // "Hello Java"

                    // Trích xuất nhóm

                    String email = "test@example.com";

                    Pattern emailPattern = Pattern.compile("(\\w+)@([\\w.-]+)");

                    Matcher emailMatcher = emailPattern.matcher(email);

                    if (emailMatcher.find()) {

                        System.out.println("Username: " + emailMatcher.group(1)); // "test"

                        System.out.println("Domain: " + emailMatcher.group(2));   // "example.com"

                    }

                }

            }

        
---

9. Các mẹo và thủ thuật khi sử dụng Regex

  • Sử dụng công cụ kiểm tra Regex trực tuyến: Các trang web như regex101.com hoặc rubular.com là cực kỳ hữu ích để thử nghiệm và gỡ lỗi biểu thức của bạn.
  • Bắt đầu từ đơn giản đến phức tạp: Đừng cố viết một biểu thức quá phức tạp ngay từ đầu. Xây dựng từng phần một.
  • Thoát ký tự đặc biệt: Luôn nhớ sử dụng \ để thoát các metacharacter nếu bạn muốn khớp chúng như ký tự nguyên bản (ví dụ: \. để khớp dấu chấm).
  • Cẩn thận với Greedy vs. Lazy: Hiểu rõ sự khác biệt để tránh các kết quả không mong muốn. Mặc định là greedy, thêm ? để chuyển sang lazy.
  • Sử dụng nhóm không ghi nhớ (?:...): Nếu bạn không cần truy cập nội dung khớp của một nhóm, sử dụng (?:...) để cải thiện hiệu suất.
  • Sử dụng cờ thích hợp: Ví dụ, i cho khớp không phân biệt chữ hoa/thường, g cho tất cả các khớp, m cho nhiều dòng.
  • Đọc tài liệu ngôn ngữ: Mặc dù cú pháp cốt lõi giống nhau, các triển khai Regex có thể có những khác biệt nhỏ (ví dụ: hỗ trợ Unicode, lookbehind, hiệu suất).
  • Đừng lạm dụng Regex: Với các tác vụ xử lý chuỗi đơn giản, đôi khi các phương thức chuỗi có sẵn của ngôn ngữ lại hiệu quả và dễ đọc hơn.
---

10. Bài tập thực hành

Hãy thử tự mình viết các Regular Expression cho các trường hợp sau:

  1. Khớp với một địa chỉ email cơ bản (ví dụ: username@domain.com).
  2. Trích xuất tất cả các URL (bắt đầu bằng http:// hoặc https://) từ một đoạn văn bản.
  3. Kiểm tra xem một mật khẩu có ít nhất 8 ký tự, bao gồm ít nhất một chữ cái viết hoa, một chữ cái viết thường và một chữ số.
  4. Tìm và thay thế tất cả các ngày tháng ở định dạng DD/MM/YYYY thành YYYY-MM-DD.
  5. Trích xuất các giá trị số từ một chuỗi dạng "Item A: 123, Item B: 456".