ย้ายบล็อกไปที่ bact.cc แล้วนะครับ

พ.ร.บ.คอมพิวเตอร์
หยุด ร่างพ.ร.บ.คอมพิวเตอร์
พื้นที่เก็บข้อมูลออนไลน์ ฟรี 2GB จาก Dropbox (sync กับ Windows, Linux, Mac, iPhone, Android ฯลฯ ได้)

2005-12-07

Ruby gsub, friends and unrelated stuffs

เราเรียกใช้เมทธอด gsub ของคลาส String (String#gsub) ใน Ruby ได้สองแบบ
จาก API doc:
คำสั่ง => ผลลัพธ์

  1. str.gsub(pattern, replacement) => new_str
  2. str.gsub(pattern) {|match| block } => new_str

ตรง pattern นี่ จะเป็น String ธรรมดา ๆ ก็ได้

เค้าให้ตัวอย่างมาแบบนี้

  • "hello".gsub(/[aeiou]/, '*') #=> "h*ll*"
  • "hello".gsub(/([aeiou])/, '<\1>') #=> "h<e>ll<o>"
  • "hello".gsub(/./) {|s| s[0].to_s + ' '} #=> "104 101 108 108 111 "

แบบ (1) ใช้เป็นแล้ว ก็เหมือน ๆ กับในภาษาอื่น ๆ
ส่วนแบบ (2) ที่ผ่าน code block เข้าเมทธอด ยังไม่เคยลอง .. ก็ลองซะคราวนี้

โจทย์คือ ผมจะแทนตัวอักษร &, ', ", < และ > ทั้งหมดในไฟล์ ๆ หนึ่ง
ด้วย XML Entity &amp;, &apos;, &quot;, &lt; และ &gt;

ตอนแรกผมทำแบบนี้


 1: def convert1 (line)
 2:  if line != nil
 3:   line.gsub!(Re_amp,  Amp)
 4:   line.gsub!(Re_apos, Apos)
 5:   line.gsub!(Re_quot, Quot)
 6:   line.gsub!(Re_lt, Lt)
 7:   line.gsub!(Re_gt, Gt)
 8:   return line
 9:  else
10:   return line
11:  end
12: end
13:
14: IO.foreach(ARGV[0]) { |line| puts convert1(line) }

โปรแกรมจะเริ่มทำงานที่บรรทัดสุดท้าย (14) โดยอ่านไฟล์ตามชื่อที่ให้มาทาง command line argument แรก 1
หลังจากนั้น ในแต่ละบรรทัดของไฟล์ ก็จะทำคำสั่ง puts convert1(line) 2
อย่างกรณีนี้ก็คือ ทุก ๆ ครั้งที่อ่าน 1 บรรทัด ก็ให้พิมพ์บรรทัดที่ถูกแปลงแล้วออกมา

เมทธอด foreach ทำให้เราเขียนโค้ดที่อาจจะยาวอย่างน้อยสามสี่บรรทัดในภาษาอื่น ได้เหลือแค่บรรทัดเดียวใน Ruby !

  • IO.foreach(name, sep_string=$/) {|line| block } => nil

อย่าเพิ่งนอกเรื่องไปไกล กลับมาที่ gsub ต่อ ...

โอเค จากบรรทัดที่ 14 จะเห็นว่าเราเรียกใช้ฟังก์ชั่น convert1, ซึ่งประกาศไว้ตรงบรรทัดที่ 1-12 (13 เป็นเลขไม่ดี เราขอไม่ใช้ :P)

บรรทัด 3-7 จะเห็นว่าเราเรียก gsub! 3 เป็นชุดเลย
โดยที่ Re_xxx นี่ เป็นออบเจกต์ pattern ที่จะหา &, ', ... ที่เราเตรียมไว้ล่วงหน้าแล้ว
และพวก Amp, Apos, ... ก็เป็น constant 4 ที่มีค่า &amp;, &apos;, ...
(ตรงนี้ผมคิดไปเองว่า ถ้าเราเตรียมแพตเทิร์นและสตริงไว้ล่วงหน้า
คงจะช่วยให้ Ruby ไม่ต้องสร้างออบเจกต์แพตเทิร์นทุกครั้งใน "ลูป" foreach (ทุก ๆ บรรทัด)
แต่เนื่องจากไม่รู้ว่า ตัว Ruby runtime ทำงานจริง ๆ ยังไง มี optimization อะไรบ้าง เลยไม่แน่ใจว่ามันจะช่วยจริงรึเปล่า
... เอาว่าช่วยทางใจละกัน อยากเขียนแบบนี้น่ะ)

โค้ดของของแพตเทิร์น (regular expression):


Re_amp  = /&(?![-_a-zA-Z0-9]{1,12};)/  # (skip & in &xxx;)
Re_apos = /\'/
Re_quot = /\"/
Re_lt   = /</
Re_gt   = />/

เอาล่ะ ถึงเรื่องที่จะเล่าละ

ทีนี้ จะเห็นว่า เฮ้ย เราเรียก gsub! ซ้ำ ๆ ตั้งหลายรอบแหน่ะ
1 gsub! ต่อ 1 คู่ (แพตเทิร์น/ตัวอักษรที่จะแทน) ต่อ 1 บรรทัด
ดู ๆ ไป (อย่างเด่นชัด, ตามคำแปลของ LEXiTRON) มันน่าจะมีวิธีที่ .. มีประสิทธิภาพมากกว่านี้สิ

เป็นไปได้รึเปล่า ที่เราจะแทน คู่ (แพตเทิร์น/ตัวอักษร) ได้มากกว่า 1 คู่ ในการเรียก gsub! 1 ครั้ง ?
คำตอบคือ เป็นไปได้ ด้วยการผ่านโค้ดบล็อกเข้า gsub!
โดยให้ตัวโค้ดบล็อกเป็นตัวตัดสินใจเรื่องการแทนที่


 1: Re_escapes = /(&(?![-_a-zA-Z0-9]{1,12};))|(\')|(\")|(<)|(>)/
 2:
 3: def escape (char)
 4:  return case char
 5:   when '&'  then Amp
 6:   when '\'' then Apos
 7:   when '\"' then Quot
 8:   when '<'  then Lt
 9:   when '>'  then Gt
10:   else char
11:  end
12: end
13:
14: def convert2 (line)
15:  if line != nil
16:   line.gsub!(Re_escapes) { |match| match = escape(match) }
17:   return line
18:  else
19:   return line
20:  end
21: end
22:
23: IO.foreach(ARGV[0]) { |line| puts convert2(line) }

ที่โค้ดนี้ จุดที่ convert2 ต่างกับ convert1 ก็คือ ตรงบรรทัดที่ 16
จะเห็นว่า แทนที่เราจะส่ง 2 พารามิเตอร์ — (pattern, replacement)
เราส่ง 1 พารามิเตอร์ กับ 1 บล็อก แทน — (pattern) { block }

โค้ดบล็อก { |match| match = escape(match) } จะถูกเรียกทุกครั้งที่ gsub! พบแพตเทิร์น Re_escapes
โดย match จะเป็นค่าของสิ่งที่พบ
เราก็จัดการแปลง match ด้วยฟังก์ชั่น escape ซะ เท่านั้นก็จบ
เท่ากับว่า เราเรียก gsub! แค่ครั้งเดียว5

ในฟังก์ชั่น escape ก็ไม่มีอะไร, แบบว่าจะโชว์การใช้ case ... when น่ะ
เราสามารถผสม return กับ case ... when ได้ด้วย (แทนที่จะเขียน then return ... ซ้ำ ๆ)

Re_escapes นี่ก็เป็นแพตเทิร์นที่เรายุบรวม Re_amp, ... Re_gt เข้าด้วยกัน ไม่มีอะไรมาก

สรุปก็คือ แทนที่เราจะหาทีละตัวอักษร แทนทีละตัวอักษร
เราก็หามันทีละหลาย ๆ ตัวอักษรเลย (ด้วยการผสมแพตเทิร์นเข้าด้วยกัน)
แล้วจากนั้นก็ค่อยไปดูว่า ที่พบน่ะ ตัวไหน แล้วค่อยแทนด้วยตัวที่ตรงคู่ (ตัดสินใจด้วยโค้ดบล็อก)

อยากรู้ว่าสองวิธีนี้ (convert1 vs convert2) จะให้ประสิทธิภาพต่างกันแค่ไหน
ลองทดสอบง่าย ๆ ด้วย Benchmark#bmbm


require 'benchmark'

...

Benchmark.bmbm do |x|
 x.report("convert1:") { IO.foreach(ARGV[0]) { |line| convert1(line) } }
 x.report("convert2:") { IO.foreach(ARGV[0]) { |line| convert2(line) } }
end

ลองรัน6 wellform-demo.rb etlex.xml

ผลลัพธ์:


Rehearsal ---------------------------------------------
convert1:  23.914000   0.070000  23.984000 ( 24.015000)
convert2:  22.813000   0.130000  22.943000 ( 22.973000)
----------------------------------- total: 46.927000sec

                user     system      total        real
convert1:  23.914000   0.100000  24.014000 ( 24.065000)
convert2:  22.913000   0.090000  23.003000 ( 23.063000)

กับไฟล์ etlex.xml ขนาดประมาณ 18 MB (668,938 บรรทัด)
convert2 เร็วกว่า convert1 ประมาณ 1 วินาที (ประมาณ 4%)

คุ้มมั๊ยเนี่ย ? :P

ก็ .. ถือว่าคุ้มละกัน เพราะได้เรียน Ruby น่ะ :D


1 ข้อสังเกต: ตรงนี้จะไม่เหมือน command line argument ในสไตล์ C หรือ Python
ที่ argument แรก (0) จะหมายถึงชื่อโปรแกรมที่กำลังรันอยู่
ใน Ruby ถ้าต้องการรู้ชื่อโปรแกรม ให้เรียกจากโลกาตัวแปร (global variable) $0 กลับ

2 ตรงนี้เราเรียกว่า code block
อย่างบรรทัด 14 นี้คือ เราส่งโค้ดบล็อก {...} เข้าเมทธอด foreach
โดย line (ใน | |) เนี่ย ก็เป็นเหมือน argument ของโค้ดบล็อก
(คิดซะว่า เรากำลังเขียนฟังก์ชั่นอันนึงก็ได้ ทำเหมือน argument ปกติ)
โดย line จะถูกแทนภายในเมทธอดที่เราส่งโค้ดบล็อกเข้าไป
(อันนี้คือตามความเข้าใจจากการทดลอง กลไกภาษา Ruby จริง ๆ อาจจะไม่ใช่แบบนี้ก็ได้ ต้องลองอ่านเอกสารดู) กลับ

3 gsub! ต่างกับ gsub ตรงที่ มันจะเปลี่ยนแปลงสตริงต้นฉบับเลย;
ในขณะที่ gsub จะไม่เปลี่ยนแปลงสตริงต้นฉบับ แต่จะคืนสตริงใหม่ออกมา;
พูดอีกอย่าง: โค้ด x.gsub!(a,b) ให้ผลเหมือน x = x.gsub(a,b)
ใน Ruby เมทธอดที่ลงท้ายด้วย ! มีลักษณะแบบนี้เหมือนกันหมด กลับ

4 by convention (ตามข้อตกลง), ค่าคงที่ (constant) ใน Ruby จะขึ้นต้นด้วยตัวพิมพ์ใหญ่ เช่น Book, Song
อย่างไรก็ตาม ข้อควรระวังคือ ในโปรแกรม เราสามารถแก้ไขค่าคงที่ได้ และ Ruby runtime ก็ไม่ห้าม
จะมีก็แค่ขึ้น warning เท่านั้น แต่โปรแกรมจะทำงานต่อไปตามปกติ
ซึ่งตรงนี้นี่ ... อืมม ผมไม่แน่ใจว่าเป็นความคิดที่ดีรึเปล่า
อย่างน้อย คนเขียน Java น่ะไม่น่าจะชอบ กลับ

5 ตรงนี้ บางคนอาจจะเห็นว่า บรรทัดที่ 16 กับ 17 น่าจะยุบรวมกันได้
ข้อควรระวังก็คือ ถ้าจะยุบ อย่าลืมเปลี่ยน gsub! เป็น gsub
return line.gsub(Re_escapes) { |match| match = escape(match) }
เพราะในกรณีที่สตริงไม่มีการเปลี่ยนแปลง, gsub! จะคืนค่า nil
แต่ gsub จะคืนค่าสตริงต้นฉบับ
สิ่งที่เราอยากได้ ในโปรแกรมนี้ คืออย่างหลัง กลับ

6 ภาษาอังกฤษ “long run” แปลว่า ในระยะยาว :P กลับ

2 comments:

polawat phetra said...

นึกว่า scan รวมครั้งเดียวจะเร็วกว่า
แต่กลายเป็นว่า pattern ที่ซับซ้อนขึ้น
ก็กินเวลามากขึ้น
ไม่รู้ว่าประโยค when กินเวลาไปด้วยหรือเปล่า

กำลังมองหา profile tool ของ ruby มาลองอยู่

bact' said...

อีกอย่าง อาจจะเป็นเพราะว่า แต่ละบรรทัดยาวไม่มากก็ได้ครับ
ถ้าเกิดแต่ละบรรทัด ยาวมากกว่านี้ ก็อาจจะเห็นความแตกต่างชัดกว่า