Tập chơi với cú pháp cơ bản của RegEx

RegEx viết đầy đủ là Regular Expression, tên tiếng Việt là biểu thức chính quy. Ưu điểm lớn nhất của RegEx: nó giúp chúng ta đỡ phải nghĩ những dòng mã cồng kềnh, vì RegEx có khả năng xử lý chuỗi rất mạnh.

RegEx cũng được nhiều ngôn ngữ lập trình, ứng dụng hỗ trợ. PHP và SQL cũng hỗ trợ RegEx rất tốt.

Mới đầu bạn nhìn cấu trúc của RegEx sẽ rất là ngao ngán, chẳng hạn đây là cú pháp bắt chuỗi ngày tháng năm tiêu chuẩn:

\b(0?[1-9]|[12]\d|3[01])[\/\-.](0?[1-9]|[12]\d|3[01])[\/\-.](\d{2}|\d{4})\b

Cái gì đây? Sao nhiều dấu \ rồi /, [, {, | thế kia, dấu ? là cái gì vậy.

Chúng ta sẽ sớm hiểu lý do, cú pháp của RegEx theo phong cách tối giản, nó sẽ sử dụng ít ký tự nhất có thể để thực hiện nhiệm vụ nào đó. Vì chúng ta quen với kiểu viết mã gần giống ngôn ngữ giao tiếp của con người (ví dụ PHP, JS) nên bộ cú pháp này quả là có khiến nhiều người (trong đó có tôi) cảm thấy ngộp lúc ban đầu.

Cách duy nhất là chúng ta sẽ thực hành, làm quen với những thứ cơ bản trước. Tôi sẽ tập cùng bạn ngày hôm nay, thời điểm tôi biết bài này thì khả năng về RegEx cũng chỉ ở mức abc, đúng ra còn chưa đến c, mới lọ mọ ở a thôi!

OK, giờ chúng ta sẽ bắt đầu. Các bạn truy cập website này: regex101.com để tập viết, nó có giao diện rất trực quan, dễ hiểu.

Giao diện của Regex 101

Trước hết chúng ta sẽ tìm hiểu mấy cú pháp cơ bản đã, trước khi viết lệnh:

  • [abc] cái này có nghĩa là nếu chuỗi cần kiểm có a hoặc b hoặc c là nó sẽ khớp (match). Khi bạn thử với các chuỗi cần kiểm tra sau, tất cả sẽ khớp:

Nghĩa là thứ tự, hay số lượng chữ cái ở đây không quan trọng. Cứ có một trong các ký tự trong dấu ngoặc vuông là OK.

PHP có hỗ trợ RegEx, nhưng giờ bạn thử không dùng đến nó, mà nghĩ một đoạn mã tìm xem một chuỗi nào đó có ký tự a hoặc b hoặc c hay không.

Có lẽ chúng ta sẽ dùng hàm $strpos để biết kết quả. Câu lệnh có lẽ sẽ thế này:

$str = "abcd"; // chuỗi demo cần kiểm tra

$pa = strpos($str, 'a');

$pb = strpos($str, 'b');

$pc = strpos($str, 'c');

if ($pa > 0 || $pb > 0 || $pc >0) {
  echo "dương tính";
}
else 
  echo "âm tính";

Rất dễ hiểu, nhưng đúng là dài thật khi so với [abc], và ưu điểm của RegEx sẽ càng tăng thêm khi chuỗi mà bạn xử lý có cú pháp phức tạp.

  • Để chỉ các ký tự chữ cái bất kỳ từ a đến z, cú pháp RegEx là [a-z] (thêm đúng dấu gạch!), ví dụ tiếng Việt không có w và z, bạn có thể viết như sau để tìm các ký tự tiếng Việt không dấu [a-vxy]: giải nghĩa a-v nghĩa là bao gồm các chữ cái liên tiếp từ a đến v, sau v là w là cái mà chúng ta bỏ qua, sau đó đến x hoặc y, cuối cùng chúng ta cũng không cho z vào.
  • Câu lệnh [^abc] có nghĩa là tìm bắt các ký tự không phải a, b và c;
  • [^a-z] có nghĩa là tìm bắt các ký tự không phải chữ cái, ví dụ chuỗi nào đó có số sẽ khớp; Chuỗi nào toàn chữ sẽ không khớp;
  • Hàm nghĩa của dấu ngoặc vuông [] với số cũng tương tự, [123] nghĩa là tìm chuỗi có một trong các số sau 1 hoặc 2 hoặc 3;
  • [0-9] nghĩa là tìm bắt các ký tự số trong chuỗi. Chỉ cần chuỗi có số là sẽ khớp;
  • [^0-9] nghĩa là tìm bắt các ký tự không phải là số trong chuỗi. Chuỗi nào toàn số sẽ không khớp, chuỗi nào chỉ cần có một ký tự khác số, ví dụ chữ là sẽ khớp;
  • Bạn thử đoán nếu nó muốn tìm bắt chữ viết hoa từ A đến Z thì viết thế nào? Bạn đoán đúng rồi đó chính là [A-Z]. Còn nếu muốn bắt cả chữ hoa và chữ thường thì sẽ là [a-zA-Z]

Giờ mà chúng ta viết câu lệnh PHP thông thường để tìm tất cả các ký tự văn bản hoa cũng như thường thì đúng là mệt nghỉ!

  • Cú pháp [0-9] bạn thấy là ngắn gọn rồi đúng không? nhưng vẫn chưa đâu, RegEx còn có cú pháp khác tương đương ngắn hơn thế. Đó là \d

\d là viết tắt của từ digit có nghĩa là số. Thế tại sao phải thêm \ kèm d nữa, sao không d thôi cho càng ngắn! Vấn đề là nếu để là d thôi thì sẽ bị lẫn với chữ cái d.

  • Để chỉ khoảng trắng giữa các ký tự RegEx sử dụng cú pháp \s

s là viết tắt của space.

  • Để chỉ các ký tự không phải là số bạn dùng cú pháp \D
  • Chỉ các ký tự không phải là khoảng trắng, chắc bạn đoán ra rồi. Đó là \S
  • Cũng giống như \d là hình thức ngắn gọn hơn nữa của [0-9]. RegEx dùng \w để chỉ tất cả cả các chữ cái hoa cũng như thường, có khác biệt chút là ngoài chữ cái ra \w còn đại diện luôn cho cả số và dấu gạch dưới. Nói cách khác \w tương đương với [a-zA-Z0-9_]. Cần lưu ý là \w không bao gồm \s

Tuy nhiên là người dùng tiếng Việt, chúng ta cần lưu ý rằng các câu lệnh liên quan đến chữ cái như [a-zA-Z] hay \w không bao gồm các chữ cái có dấu của tiếng Việt như là ư, ô, á, ạ, vân vân. Bạn có thể tham khảo bài này để biết cách khắc phục, đơn giản là chúng ta cần bổ sung thêm các chữ cái của tiếng Việt vào là được.

  • \W chắc bạn đoán ra ý nghĩa của nó rồi, để bắt tất cả các ký tự không phải là chữ cái cũng như chữ số, ví dụ như !@#$%, vân vân.
  • Ký tự . trong RegEx có nghĩa là tất cả các kiểu ký tự, chữ cái, chữ số, ký tự đặc biệt, vân vân. Vì thế trong chuỗi bất kỳ bạn gõ . là nó sẽ bắt tất cả
  • Vì RegEx đã dùng dấu . với hàm nghĩa khác dấu . bình thường, nên để chỉ dấu . bình thường bạn lại phải kết hợp thêm dấu \, nghĩa là dấu . bình thường bây giờ sẽ là \.

Ví dụ để khớp ngày tháng có cấu trúc kiểu như 2.3 (ngày mùng 2 tháng 3) bạn sẽ phải viết thế này \d\.\d (trông vẫn quái dị nhưng giờ dễ hiểu hơn rồi! Nó có nghĩa là số rồi tiếp đến là . rồi lại là số)

  • Ví dụ trên đã âm thầm nó đến một thứ phức tạp hơn đó là thứ tự trong RegEx. \d chỉ đại diện cho một số, cũng vậy [0-9] cũng chỉ đại diện cho một số khi mà có sự kết hợp;
  • Nếu chỉ có \d nó sẽ khớp với cả 67 lẫn 6, nhưng nếu là \d\d thì chỉ 67 là khớp, 6 lúc này không liên quan nữa. Vì \d\d nói rằng chuỗi cần tìm phải có 2 số cơ.
  • Cũng vậy \d\.\d cũng chỉ khớp với 6.7 hoặc 5.3 chứ 23, 45 lúc này không liên quan nữa vì 23 và 45 làm gì có dấu .
  • Tương tự [a-z][a-z] thì nó sẽ chỉ khớp với chuỗi nào có 2 ký tự đứng cạnh nhau chứ có 1 ký tự là không khớp nữa, hoặc kể cả có 2 ký tự nhưng ngăn nhau bằng dấu cách như này a b cũng không khớp. Muốn khớp với chuỗi a b thì RegEx sẽ phải là [a-z]\s[a-z]

Giờ chúng ta sẽ đi giải thích vấn đề là tại sao \d thì khớp với cả 67 và 6, mà \d\d thì lại chỉ khớp với 67 thôi chứ không còn khớp với 6 nữa. Cái này rất quan trọng đấy ạ, tôi cũng chỉ vừa mới nhận ra, vì \d cũng chỉ có một số thôi sao nó lại khớp được với số có 2 số là 67 kia?

Điều đầu tiên bạn cần làm là phóng to màn hình của regex101 để dễ quan sát.

Với lệnh /d bạn sẽ thấy nó khớp với 2 match của chuỗi 67 (hai mầu xanh nhạt và đậm, bạn di con trỏ chuột vào sẽ thấy hiện lên chữ match 1, match 2). Chuỗi 6 bên dưới chi khớp 1 lần.

cách RegEx tìm chuỗi

Như vậy ở đây có nghĩa là chuỗi trong RegEx sẽ được khớp từ trái qua phải với chuỗi cần tìm (trong ví dụ trên là 67 và 6), nếu khớp chính xác nó sẽ đi tiếp để tìm trong chuỗi còn phần nào khớp nữa không.

Giờ nếu bạn đánh lệnh Rx là \d\d thì sẽ chỉ có một khớp trong 67, và không có khớp (match) nào trong chuỗi bên dưới cả (không có màu).

cách khớp của Regex

Một ví dụ khác cho rõ ràng hơn. Cũng vẫn là lệnh \d\d nhưng với các chuỗi như hình bên dưới:

ví dụ tiếp về cách RegEx so khớp chuỗi

Ở đây chuỗi 111af23 sẽ tìm thấy 2 vị trí khớp với lệnh \d\d là 11 và 23

Còn chuỗi bên dưới là 12, 34 và 24. Số 4 đã được sử dụng trong chuỗi 24 rồi nên không được dùng nữa, vì vậy không có khớp 42. Và chuỗi này có 3 khớp với lệnh \d\d

Bây giờ bạn chuyển màn hình về kích cỡ bình thường được rồi. Giờ chúng ta sẽ đi vào các ví dụ khác của RegEx.

  • Có một điều chúng ta cần để ý về RegEx là mỗi lệnh của nó như là một đoạn văn độc lập. Bạn có nhớ lệnh [^abc] để khớp với các ký tự đơn không phải a, b, c? Giờ bạn đoán lệnh bên dưới có ý gì?
^abc

Cả hai trông có vẻ rất giống nhau, vì đều có ^, cứ kiểu như 2 từ có cùng chữ cái vậy, chắc là có liên quan rồi?

Nhưng lại không liên quan gì!

Câu lệnh trên yêu cầu chuỗi cần tìm phải bắt đầu bằng chuỗi abc. Tức là nó sẽ chỉ khớp với chuỗi abc, abcde, chứ không phải chuỗi dabc, vì chuỗi này có d đứng trước abc rồi. Nếu bỏ dấu ^ đi trong RegEx thì dabc cũng khớp.

  • Ngược lại yêu cầu khớp đầu chuỗi ^ là khớp cuối chuỗi $, ví dụ abc$ sẽ khớp với các chuỗi như dabc, fffffabc, nhưng không khớp với chuỗi abcd, hoặc mabcd
  • Lệnh ^abcabc$ sẽ đều khớp với chuỗi abc vì chuỗi này vừa là đầu vừa là cuối trong chính nó;

Giờ chúng ta sẽ chuyển sang phần kiểm soát số lần xuất hiện của một ký tự nào đó. Để dễ hiểu tôi sẽ đưa ra ví dụ.

Bây giờ chúng ta phải xử lý dữ liệu ngày tháng năm theo chuẩn dd/mm/yyyy. Chưa nói đến phần tháng và năm, trong ví dụ này chúng ta chỉ cần quan tâm đến ngày.

dd không phải lúc nào cũng được viết theo chuẩn đó tức là dưới 10 thì có số 0 đằng trước, ví dụ 05/07/2015. Rất thường xuyên định dạng này có dạng 5/7/2015.

Tức là ngày có thể có 1 số hoặc 2 số, và quan trọng hơn, khi nó ở dạng 2 số, có thể có số 0 đằng trước mà vẫn hợp lệ.

Câu lệnh RegEx để giải quyết cái này là:

0?\d

Điểm nhấn ở đây là dấu ?

Khi dấu hỏi xuất hiện sau một ký tự thì có nghĩa là ký tự đó không tồn tại hoặc chỉ xuất hiện một lần.

Nói cách khác, khi tìm chuỗi, Regex thực tế sẽ chạy 2 chuỗi kiểm tra. Một chuỗi chỉ là \d và một chuỗi là 0\d

  • Tiếp theo là dấu + sau một ký tự có nghĩa là ký tự có thể xuất hiện một hoặc nhiều lần. Ví dụ lệnh \s+ sẽ khớp với abc dabc dab cd
  • Ý của \s+ là khoảng trắng có thể xuất hiện một lần hoặc nhiều lần. Trong khi \s cũng sẽ khớp với các chuỗi trên nhưng là nhiều match, còn với \s+ chỉ có 1 match trong từng chuỗi. Nhờ vậy bạn có thể dùng \s+ để xóa các khoảng trắng thừa.

Ngoài dấu ? và +, chúng ta còn một dấu nữa là *, khi dùng sau ký tự nào đấy nó chỉ đến việc ký tự ấy có thể không tồn tại, hoặc lặp lại nhiều lần.

  • Phần tiếp theo chúng ta sẽ nói về việc chỉ định cụ thể một ký tự nào đó được lặp lại bao nhiêu lần.
  • Lấy lại ví dụ ngày tháng năm. Nếu chúng ta áp dụng chuẩn năm gồm 4 chữ số thì thay vì phải ghi \d\d\d\d chúng ta có thể ghi ngắn gọn hơn bằng cách viết \d{4}, tức chuỗi đó phải có chính xác 4 số;
  • Trong một ví dụ khác không liên quan đến ngày nữa, với lệnh a{4,} điều đó đồng nghĩa với việc ký tự a được lặp lại từ 4 lần trở lên;
  • Nếu chúng ta muốn một ký tự biến thiên trong một khoảng xác định ví dụ chỉ từ 4 đến 6, chúng ta viết như sau a{4,6} khi đó 2aaaa, 2aaaaa, 2aaaaaa đều khớp, nhưng 2aaa sẽ không khớp vì số lượng ký tự a của nó là 3. Đố bạn 2aaaaaaa (có 7 chữ a, thì câu lệnh RegEx a{4,6} có khớp không?)

Thử cái biết liền. Khác với trực quan, chúng ta nghĩ 7 > 6 thì sẽ không match. Nhưng nó vẫn match, vì ở đây RegEx vẫn khớp được 6 ký tự đầu trong 7 ký tự a trong chuỗi 2aaaaaaa

Tiếp theo là một dấu dễ hiểu với đa số các bạn đang đọc bài này, đó là dấu |để chỉ là hoặc giá trị bên trái dấu | hoặc giá trị bên phải dấu |

Ví dụ trong lệnh 51|71 nó sẽ lấy chuỗi 51 đem đi match với chuỗi cần tìm, rồi tiếp tục lấy chuỗi 71 đi so sánh. Chỉ cần một trong 2 chuỗi match là được.

Tôi sẽ kết thúc bài này với hai cú pháp mà bản thân vẫn lăn tăn chưa hiểu hết, nhưng vẫn đánh liều trình bày. Mọi người thấy sai thì sửa nhé.

Đó là lệnh \b và ngoặc đơn ()

Tôi sẽ cần dùng ví dụ để chúng ta tìm hiểu lệnh này.

Hãy quay lại lệnh RegEx chuẩn chỉnh để kiểm tra chính xác định dạng ngày tháng năm.

\b(0?[1-9]|[12]\d|3[01])[\/\-.](0?[1-9]|[12]\d|3[01])[\/\-.](\d{2}|\d{4})\b

Giờ chúng ta sẽ phân tích từng cái một:

  • 0?[1-9] nghĩa là sẽ cần match định ngày có số 0 đằng trước ví dụ như 05, và kể cả định dạng không có số 0, ví dụ 5. Dùng 0?[1-9] là chính xác thay vì dùng 0?\d
  • Đố bạn biết tại sao? Câu trả lời là nếu để 0?\d thì định dạng sai 00 vẫn được phép (vì \d bao gồm từ 0 đến 9);
  • [12]\d cái này có nghĩa là nó match với khoảng 10 cho đến 29;
  • 3[01] cái này có nghĩa là nó match với 30 hoặc 31
  • Như vậy bằng cụm 0?[1-9]|[12]\d|3[0\1] nó đã khống chế giá trị chỉ gồm 2 số và chạy từ khoảng 01 cho đến 31, tức là giá trị hợp lệ của một ngày
  • Tiếp theo là cụm [\/\-.] chúng ta biết rằng ký tự để trong dấu ngoặc việc chỉ để hoặc, như trường hợp [abc] nghĩa là lấy một ký tự rồi lần lượt đem đi match;
  • Có lẽ bạn nghĩ chỉ cần viết [/-.] là đủ, sao lại cần thêm hai dấu \ làm gì. Lý do vì các ký tự này trùng với lệnh của RegEx, tôi còn nghĩ đáng ra phải cần 3 dấu \, cho cả dấu . nữa, nghĩa là phải thế này [\/\-\.] mới an tâm. Thực ra 2 \ lệnh vẫn chạy chính xác.
  • Ngoài cách dùng [\/\-\.] các bạn có thể dùng (\/|\-|\.) cũng cho kết quả tương tự;
  • 2 phần tiếp theo là câu lệnh tương tự 2 phần đầu. Lý do không chắc chắn cụm số ở giữa đã chỉ tháng nên chúng ta vẫn để khoảng thay đổi từ 0 tới 31;
  • Phần cuối là đoạn \d{2}|\d{4} chúng ta hiểu rằng cái này yêu cầu khớp {2} số\d hoặc| {4} số\d. Tương ứng với định dạng yy hoặc yyyy của năm;
  • Như vậy là cấu trúc của lệnh RegEx được hiểu tương đối rõ và nó phù hợp với định dạng dữ liệu mà chúng ta muốn so khớp;

Bây giờ là câu hỏi quan trọng, dấu () có thể hiểu đơn giản là nhóm các chuỗi cần kiểm tra trước. Vậy /b để làm gì?

Khi câu lệnh chuẩn 4 chuỗi dưới đây khớp:

  • 01-15-2017
  • 11/05/2017
  • 1/05/17
  • 05/1/17

Còn 3 chuỗi dưới đây không khớp bất ký match nào:

  • 111/05/1777
  • 11/13/190
  • 11/13/19000

Nhưng khi bỏ \b ở cuối thì 2 chuỗi sau lại khớp:

  • 11/13/190
  • 11/13/19000

Như vậy bỏ\b ở cuối làm mất hiệu lực của \d{4} làm cho chuỗi năm ở dạng 3 số hoặc 5 số vẫn được chấp nhận.

Khi bỏ cả \b đằng trước thì nó match cả 3 chuỗi trước đây không nhận:

  • 111/05/1777
  • 11/13/190
  • 11/13/19000

Như vậy bỏ \b trước làm mất hiệu lực của việc chỉ có 2 ký tự số được phép.

Qua ví dụ thực tế như vậy ta đã biết nhiệm vụ của \b là nó yêu cầu RegEx khớp chính xác với chuỗi lệnh của nó chứ không phải tìm chuỗi (RegEx) bên trong chuỗi cần so sánh. Nói cách khác với \b bao đầu và cuối chuỗi nó làm cho câu lệnh RegEx == với chuỗi so sánh thì mới match, thay vì chỉ cần là chuỗi con là đủ như thông thường.

Ít nhất với câu lệnh trên thay vì dùng \b chúng ta có thể dùng 2 lệnh khác và cho kết quả tương tự. Đó là ^ cho phần đầu, và $ cho phần cuối. ^ sẽ bắt buộc khởi đầu chuỗi chỉ có 1 hoặc 2 phần tử số, còn $ làm cho kết thúc chuỗi chỉ được phép là 2 hoặc 4 phần tử số:

^(0?[0-9]|[12]\d|3[01])[\/\-\.](0?[1-9]|[12]\d|3[01])[\/\-\.](\d{2}|\d{4})$

Xin chào, và hẹn gặp lại bạn trong bài viết khác.