Dart: วิธีการค้นหาและแทนที่ข้อความด้วย RegExp class

ในการค้นหาและแทนที่ข้อความใน Dart หากใช้งานผ่าน String class จะมีคำสั่งที่ช่วยในการทำงานได้แก่

ใช้ค้นหา

ใช้แทนที่

คำสั่งเหล่านี้ หากผู้ใช้ต้องการค้นหาหรือแทนที่ด้วยข้อความที่มีรูปแบบแบบข้อความธรรมดา ก็สามารถใช้ข้อความที่สนใจได้ทันที โดยหากดูใน API จะพบว่าตัวข้อความที่ใช้ค้นหา จะเป็น Pattern class มันเป็น interface ที่ใช้สำหรับการค้นหาข้อความแบบพื้นฐาน

String animal = "cat bat rat";

print(animal.indexOf('bat')); // output → 4
print(animal.replaceRange(4, 7, 'BAT')); // output → cat BAT rat

print(animal.contains('rat')); // output → true
print(animal.startsWith('cat')); // output → true
print(animal.startsWith('Cat')); // output → false
print(animal.endsWith('rat')); // output → true
print(animal.replaceAll('t', 'T')); // output → caT baT raT
print(animal.replaceFirst('t', 'T')); // output → caT bat rat
print(animal.replaceAll('rat', 'mouse')); // output → cat bat mouse

หากผู้ใช้งานจำเป็นต้องการค้นหาข้อความในรูปแบบที่กำหนด ไม่ใช่เป็นข้อความตรง ๆ ตามตัวอย่างข้างต้น Dart จะมี RegExp class ช่วยในการสร้างรูปแบบตามที่ผู้ใช้ต้องการ รูปแบบข้อความที่ใช้ใน RegExp เรียกกว่า regular expression pattern เป็นรูปแบบโดยการใช้ข้อความ ตัวอักษร สัญญาลักษณ์ต่าง ๆ แทนรูปแบบที่ต้องการ โดยรูปแบบสามารถเขียนได้ตั้งแต่ง่าย ๆ จนถึงซับซ้อน

แนวคิดของ regular expressions เริ่มต้นขึ้นในปี ค.ศ.1950 เมื่อนักคณิตศาสตร์ชาวอเมริกัน Stephen Cole Kleene ได้กำหนดแนวคิดของ regular language ซึ่งเป็นภาษาที่มีแบบแผนในการกำหนดวิธีการสะกดหรือลำดับตัวอักษร เพื่ออธิบายรูปแบบข้อความที่ต้องการ ใน Dart จะใช้รูปแบบเดียวกับมาตรฐานของ JavaScript regular expressions

การใช้ RegExp จะช่วยให้งานบางอย่างทำเสร็จเร็วขึ้น แต่อาจต้องแลกกับเวลาที่ใช้ในการประมวลผลรูปแบบของ RegExp ที่เพิ่มเข้ามา โดยหากเป็นรูปแบบที่ซับซ้อน การใช้ RegExp จะช่วยประหยัดเวลาในการทำงานมาก เพราะไม่ต้องเขียนคำสั่งเพื่อจัดการข้อความที่อาจต้องใช้เวลาเขียนเยอะ ตัวอย่าง ลองแปลงคำในข้อความเป็น proper case ด้วย RegExp

String animal = "cat bat rat";
RegExp properCase = RegExp(r'(\w)(\w+)');

String result = animal.replaceAllMapped(
  properCase, 
  (Match m) => "${m[1]!.toUpperCase()}${m[2]}"
);

print(result); // output → Cat Bat Rat

การสร้าง RegExp

สิ่งที่จำเป็นในการสร้าง RegExp ก็คือข้อความ source ที่เป็น regular expressions และเงื่อนไขในการค้นหา ดังนี้

RegExp(
  String source, {
  bool multiLine = false,
  bool caseSensitive = true,
  bool unicode = false,
  bool dotAll = false,
})

ในกรณีที่ผู้ใช้งานระบุ source ที่ไม่ใช่ regular expressions จะทำให้เกิด FormatException error ขึ้น

RegExp test = RegExp('moon');
print(test.hasMatch("The moon so big")); // output → true
print(test.hasMatch("The sun is bigger")); // output → false

RegExp notValid = RegExp('?'); // FormatException: Nothing to repeat ?

เครื่องมือช่วยเขียน regular expressions

ในชีวิตจริง การเขียน regular expressions ไม่ใช่เรื่องง่าย เนื่องจากเป็นภาษาที่อธิบายรูปแบบของข้อความที่ต้องการ แม้ว่าตัว VSCode จะช่วยเตือนตั้งแต่ก่อน compile แล้วว่าตัว regular expressions ไม่ถูกต้อง ดังนั้นการใช้เครื่องมือช่วยในการเขียน regular expressions น่าจะเป็นทางออกที่ดีกว่า

การแจ้งเตือน regular expressions ไม่ถูกต้องใน VSCode

เครื่องมือช่วยในการเขียน regular expressions แบบ Online ที่ใช้งานง่าย มีคำอธิบาย พร้อมทั้งยั้งสามารถทดสอบการทำงานได้ ส่วนตัวใช้ https://regex101.com ตัว engine ใช้ JavaScript ซึ่ง Dart ก็ใช้งานตัวนี้ด้วยเช่นกัน

https://regex101.com

regular expressions เบื้องต้น

ก่อนที่จะไปพูดถึง RegExp กันต่อ จะขออธิบายในส่วนของ regular expressions เบื่องต้น ที่ใช้กันบ่อย ๆ

ข้อความตัวอักษรธรรมดา

รูปแบบข้อความธรรมดาที่เป็นตัวอักษร สามารถระบุได้โดยตรงได้เลย เช่น moon sun หมูเด้ง หากใช้ข้อความตรงไปตรงมา จะมีค่าไม่ต่างจากการเปรียบเทียบข้อความแบบปกติที่ใช้ใน String class

String hippo = 'หมูเด้ง';  
String message = "หลายคนที่ติดตาม เพจขาหมูแอนด์เดอะแก๊ง ได้ติดตามพัฒนาการของหมูเด้ง"
    " ลูกฮิปโปแคระตัวตึง ซุปตาร์ดวงใหม่ของสวนสัตว์เปิดเขาเขียว";

// test use RegExp
RegExp testReg = RegExp(hippo);
print(testReg.hasMatch(message)); // output → true

// test use simple contains() method
print(message.contains(hippo)); // output → true

เมื่อทำใน regex101.com จะมีคำอธิบาย และผลของการจับคู่คำที่ตรงกับ regular expressions

ผลการทดสอบใน regex101.com

ใช้ . เพื่อแทนตัวอักษรใด ๆ

ใน regular expressions จะใช้ . แทนตัวอักษรใด ๆ ก็ได้จำนวน 1 ตัวอักษร รวมถึงช่องว่าง และอักษรพิเศษต่าง ๆ

RegExp anyChar = RegExp('.');
anyChar.allMatches("abc 123 ").forEach((RegExpMatch m) {
  print(m.group(0));
});

ผลที่ได้

a
b
c

1
2
3

ตัวอักษรว่างและไม่ว่าง \s \S

ตัว . จะแทนตัวอักษรทุกตัว หากต้องการแยกเป็นตัวอักษรปกติ กับ ตัวอักษรที่เป็นช่องว่าง (white space) สามารถใช้

  • \s แทนตัวอักษรที่เป็นช่องว่าง ได้แก่ space, tab, line feed (newline), carriage return, form feed, vertical tab
  • \S แทนตัวอักษรปกติที่ไม่ใช่ตัวอักษรว่าง

ตัวอย่างข้างล่าง จะเป็นการเลือกเฉพาะตัวอักษร ไม่รวมช่องว่าง ผลที่ได้ตัว space จะไม่ถูกนำมารวมในผลการจับคู่

RegExp anyChar = RegExp(r'\S'); // match a none white space
anyChar.allMatches("abc 123 ").forEach((RegExpMatch m) {
  print(m.group(0));
});

ผลที่ได้

a
b
c
1
2
3

ตัวเลข ไม่ใช่ตัวเลข \d \D

  • \d แทนตัวเลขใด ๆ 0 ถึง 9
  • \D แทนตัวอักษรที่ไม่ใช่ตัวเลข 0 ถึง 9
RegExp anyChar = RegExp(r'\d'); // match a digit
anyChar.allMatches("abc 123 ").forEach((RegExpMatch m) {
  print(m.group(0));
});

ผลที่ได้

1
2
3

กลุ่มตัวอักษรที่สนใจ [...]

สามารถกำหนดกลุ่มตัวอักษรที่ต้องการได้ดังนี้

  • [abcz] เฉพาะตัวอักษร a b c z จำนวน 1 ตัว
  • [ก-ฮ] เฉพาะตัวอักษร ก ถึง ฮ จำนวน 1 ตัว
  • [A-Z0-9] เฉพาะตัวอักษร A ถึง Z หรือ 0 ถึง 9 จำนวน 1 ตัว

หากต้องการกำหนดกลุ่มตัวอักษรที่ไม่ต้องการ ก็สามารถทำได้โดยการใช้ ^

  • [^abcz] ตัวอักษรที่ไม่ใช่ a b c z จำนวน 1 ตัว
  • [^ก-ฮ] ตัวอักษรที่ไม่ใช่ ก ถึง ฮ จำนวน 1 ตัว
  • [^A-Z0-9] ตัวอักษรที่ไม่ใช่ A ถึง Z และ 0 ถึง 9 จำนวน 1 ตัว
Dsmurat, penubag, Jelican9, CC BY-SA 4.0

ตัวอักษรพิเศษต่าง ๆ เช่น + * . ? เมื่ออยู่ใน [ ] จะถูกตีความหมายว่าเป็นอักษรนั้น ๆ ไม่ใช่อักษรพิเศษอีกต่อไป

การกำหนดจำนวนซ้ำ

สามารถใช้รูปแบบด้านล่างต่อท้ายเพื่อใช้ระบุจำนวนที่ต้องการ

  • ? 0 ตัว หรือ 1 ตัว
  • * 0 ตัว หรือมากกว่า
  • + 1 ตัว หรือมากกว่า
  • {3} 3 ตัว
  • {3,} 3 ตัวหรือมากกว่า
  • {3,6} 3 ถึง 6 ตัว
RegExp anyChar = RegExp(r'\d{3}'); // match 3 digits
anyChar.allMatches("abc 123 ").forEach((RegExpMatch m) {
   print(m.group(0));
});

ผลที่ได้

123

รูปแบบทางเลือก |

รูปแบบทางเลือกใช้สำหรับจับคู่โดยหากรูปแบบแรกไม่ตรง ให้ดูรูปแบบที่สอง เช่น ant|cat คือเลือกจับคู่ ant หรือ cat

RegExp regChoose = RegExp('ant|cat');
regChoose.allMatches("ant bat cat").forEach((RegExpMatch m) {
  print(m.group(0));
});

ผลที่ได้

ant
cat

การ escape สัญลักษณ์พิเศษ

หากต้องการกำหนดรูปแบบกับตัวอักษรพิเศษที่ใช้มาข้างต้น ได้แก่ \ [ ] { } . ? * | < > ให้ใส่ \ ไว้ข้างหน้าเหมือน escape string ใน Dart ตัวอย่างต่อไปนี้จะเป็นการเขียนเพื่อจับคู่ *** และ [abc]

String test = " *** [abc] ";

RegExp star = RegExp(r'\*{3}'); // match ***
star.allMatches(test).forEach((RegExpMatch m) {
  print(m.group(0));
});

RegExp special = RegExp(r'\[.+\]'); // match [....]
special.allMatches(test).forEach((RegExpMatch m) {
  print(m.group(0));
});

ผลที่ได้

***
[abc]

sub pattern การแบ่งส่วนที่จับคู่ออกเป็นกลุ่มย่อย

อีกความสามารถที่ใช้บ่อยคือ การทำ sub pattern เพื่ออ้างถึงส่วนของข้อความที่สนใจ การกำหนด sub pattern ทำได้โดยการใช้ ( ) ครอบส่วนที่ต้องการเอาไว้ เช่น การแยกส่วนของอีเมลแบบง่าย ๆ เป็นชื่อ กับ โดเมน โดยใช้ ([^@]+)@(.+)

  1. ในวงเล็บแรก คือส่วนของชื่อ ชื่ออีเมลจะมี @ ไม่ได้
  2. ในวงเล็บที่สอง คือส่วนของโดเมน ที่จะเป็นส่วนที่อยู่ด้านหลัง @ ทั้งหมด

ทดสอบใน regex101.com

RegExp simpleEmail = RegExp(r"([^@]+)@(.+)");
Iterable<RegExpMatch> matchs = simpleEmail.allMatches("info@google.com");

print(matchs.length); // output → 1

RegExpMatch match = matchs.first;

print(match.groupCount); // output → 2

for (var i = 0; i <= match.groupCount; i++) {
  print("Group: $i → ${match.group(i)}");
}

ผลที่ได้

1
2
Group: 0 → info@google.com
Group: 1 → info
Group: 2 → google.com

จะเห็นว่าผลที่ได้จากการจับคู่ของกลุ่ม 1 และ 2 เหมือนกับที่แสดงใน regex101.com

ส่วนอื่น ๆ ที่ไม่ได้กล่าวถึง

  • การใช้ ^ และ $
  • การกำหนด multi line และพฤติกรรมของ . ที่เปลี่ยนไป
  • ตัวอักษรพิเศษ \n \r \t \0
  • sub pattern ที่ไม่นำมารวมใน group ด้วย (?: )
  • Backreference ด้วย \1 \2

การทดสอบว่ามีส่วนใดที่ตรงกับ pattern หรือไม่ด้วย .hasMatch()

.hasMatch() คำสั่งนี้จะคล้ายกับ .contains() ของ String แต่เปลี่ยนไปทดสอบตาม RegExp ที่กำหนดแทน ตัวอย่างทดสอบว่ามี รหัสไปรณีย์ หรือไม่

RegExp postCode = RegExp(r'[1-9]\d{4}');
bool result = postCode.hasMatch("ที่อยู่ 12/13 หาดใหญ่ สงขลา 90110");
print(result); // output → true

อธิบายรูปแบบ [1-9]\d{4}

  1. [1-9] ตัวเลข 1 ถึง 9 จำนวน 1 ตัว
  2. \d{4} ตัวเลข 0-9 จำนวน 4 ตัว

หาข้อความที่เข้าคู่กับ regular expression

สามารถเลือกใช้ได้ 2 แบบคือ

  • .firstMatch() ค้นหาการจับคู่แรกของ regular expression กับข้อความที่กำหนด ผลที่ได้เป็น RegExpMatch?
  • .allMatches() ค้นหาการจับคู่ทั้งหมดของ regular expression กับข้อความที่กำหนด ผลที่ได้เป็น Iterable<RegExpMatch>

การใช้งานก็ตรงไปตรงมา หากต้องการจับคู่เพื่อเอาข้อมูลชุดแรกที่เจอ หรือข้อมูลที่จะนำไปจับคู่มีแค่คู่เดียวหรือไม่มี การใช้ .firstMatch() จะสมเหตุสมผลมากกว่า และมีประสิทธิภาพในการทำงานมากกว่า และหากไม่เจอข้อความที่จับคู่ได้ก็จะได้ค่ากลับมาเป็น null แต่หากต้องการข้อมูลทุกตัวที่จับคู่ได้ก็ใช้ .allMatches()

ผลที่ได้จากการจับคู่ RegExpMatch class

RegExpMatch class เป็น class ที่จะเก็บผลการจับคู่ สามารถประยุกต์ใช้งานได้หลายแบบ ตามข้อมูลที่ได้จากข้อความที่จับคู่ได้ โดยตัว RexExpMatch เป็นการ implement Match class อีกที

RegExpMatch class

ตำแหน่งที่พบ .start .end

คืนค่าตำแหน่งที่พบตรงกับ regular expression ที่กำหนด สามารถนำค่านี้ไปใช้เพื่อแทนที่ข้อความดังกล่าวได้

String animal = "cat bat rat";
RegExp bat = RegExp('bat');
RegExpMatch? match = bat.firstMatch(animal);

if (match != null) {
  print(animal.replaceRange(match.start, match.end, 'BAT')); // output → cat BAT rat
} else {
  print('not found "bat"');
}

การทำงานกับ sub pattern

ใน regular expression ที่มีการกำหนด sub pattern หรือ group สามารถเข้าถึงข้อมูลของ group ที่พบได้ดังนี้

  • .groupCount จำนวน group ที่พบ
  • .group() คืนค่าข้อความที่ตรงกับ sub pattern แต่ละตำแหน่ง โดย .group(0) จะเป็นข้อความทั้งหมดที่ตรงกับ regular expression
String allEmail = "contact@simple.zzz info@simple.zzz";
RegExp email = RegExp(r'([^@]+)@(\S+)'); // match email address with 2 sub patterns
RegExpMatch? match = email.firstMatch(allEmail);

if (match != null) {
  print(match.groupCount); // output → 2
  print(match.group(0)); // output → contact@simple.zzz
  print(match.group(1)); // output → contact
  print(match.group(2)); // output → simple.zzz
}

วิธีการนับ index ของ .group()

หากใน regular expression ที่ใช้ไม่มีการใส่ sub pattern เลย ผลที่ได้จะเป็นดังตัวอย่างต่อไปนี้
String allEmail = "contact@simple.zzz info@simple.zzz";
RegExp email = RegExp(r'[^@]+@\S+'); // remove sub pattern
RegExpMatch? match = email.firstMatch(allEmail);

if (match != null) {
  print(match.groupCount); // output → 0
  print(match.group(0)); // output → contact@simple.zzz
  print(match.group(1)); // RangeError: Value not in range: 1
  print(match.group(2)); // RangeError: Value not in range: 2
}

การเข้าถึง group ด้วย []

ในกรณีที่ต้องการเข้าถึง group ด้วยการเขียนที่สั้นกว่า สามารถใช้ [] แทนได้

String allEmail = "contact@simple.zzz info@simple.zzz";
RegExp email = RegExp(r'([^@]+)@(\S+)'); 
RegExpMatch? match = email.firstMatch(allEmail);
if (match != null) {
  print(match[0]); // output → contact@simple.zzz
  print(match[1]); // output → contact
  print(match[2]); // output → simple.zzz
}

การดึง group ที่สนใจออกมาเป็น List ด้วยคำสั่ง .groups()

หากต้องการดึงข้อมูล group ที่จับคู่ได้ออกมาเป็น List โดยกำหนดตัวหมายเลข group ที่สนใจ สามารถใช้คำสั่ง .groups() มีรูปแบบการใช้งานดังนี้

List<String?> groups( 
  List<int> groupIndices 
)

ให้ผู้ใช้ส่ง List ของเลข group ที่ต้องการเข้าไป ก็จะได้ List ของข้อความที่อยู่ใน group นั้นออกมา โดยเลข group ก็คือเลขเดียวกันที่ใช้กับคำสั่ง .group()

String allEmail = "contact@simple.zzz info@simple.zzz";
RegExp email = RegExp(r'([^@]+)@(\S+)');
RegExpMatch? match = email.firstMatch(allEmail);
if (match != null) {
  List<String?> result = match.groups([1, 2]);
  print(result); // output → [contact, simple.zzz]
  print(result.join('@')); // output → contact@simple.zzz
}

การตั้งชื่อ sub pattern หรือ group name

นอกจากการใช้เลขลำดับเพื่อเข้าถึงข้อความที่ตรงกับ sub pattern ที่กำหนดแล้ว ยังสามารถใส่ชื่อเพื่ออ้างถึง group ได้ผ่านคำสั่ง .namedGroup() วิธีการกำหนดชื่อใน sub pattern (?<name>)

การกำหนดชื่อให้กับ sub pattern

String allEmail = "contact@simple.zzz info@simple.zzz";
RegExp email = RegExp(r'(?<account>[^@]+)@(?<domain>\S+)');
RegExpMatch? match = email.firstMatch(allEmail);
if (match != null) {
  print(match.groupCount); // output → 2
  // access via group name
  print(match.namedGroup('account')); // output → contact
  print(match.namedGroup('domain')); // output → simple.zzz
  // you can access via group number too.
  print(match.group(1)); // output → contact
  print(match.group(2)); // output → simple.zzz
}

หากต้องการตรวจสอบว่ามี group name อะไรบ้างที่จับคู่ได้ สามารถใช้คำสั่ง .groupNames() เพื่อคืนค่า Iterable ของ group name ทั้งหมดมาให้

String allEmail = "contact@simple.zzz info@simple.zzz";
RegExp email = RegExp(r'(?<account>[^@]+)@(?<domain>\S+)');
RegExpMatch? match = email.firstMatch(allEmail);
if (match != null) {
  print(match.groupCount); // output → 2
  print(match.groupNames); // output → (account, domain)
}