เพิ่มความทนทานต่อความผิดพลาดในแมโคร

ตั้งแต่ทำงานผมก็จับแต่ PHP ซึ่งเป็นภาษาโปรแกรมสมัยใหม่ มีการจัดการความผิดพลาดที่ยืดหยุ่นและมีประสิทธิภาพแต่พอกลับมาใช้งาน VBA ที่ใช้เขียนแมโครถึงกับต้องมารำลึกความหลังว่าตัว VBA นั้นมีวิธีการจัดการเรื่องความผิดพลาดอย่างไรบ้าง เพราะยิ่งทำงานโดยเฉพาะงานที่ให้แผนกอื่นใช้พบว่า เราไม่สามารถคาดเดาอะไรได้เลยว่าผู้ใช้จะใส่อะไรเข้ามาในโปรแกรม ที่ผ่านเจอมาหลากหลายรูปแบบมาก

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

ยังมีอีกเยอะแยะตาแป๊ะมาก ๆ แต่หลัก ๆ ก็เจอประมาณนี้ การป้องกันความผิดพลาดหรือจัดการความผิดพลาดนั้นเป็นเรื่องที่สำคัญมาก ๆ เพราะช่วยให้ผู้ใช้งานไม่มึนตรึ๊บกับกล่องข้อความที่อ่านแล้วไม่เข้าใจเลย ยิ่งถ้าผู้ใช้ไม่ได้อยู่ที่ไซต์งานแล้วแล้วยิ่งปวดหัวไปกันใหญ่เพราะเราก็มึนว่ามัน Error ตรงไหน

ผู้ใช้งานเจอแบบนี้ขึ้นมาถึงกับไปต่อไม่ถูกเลย
มีปุ่ม Help ลองกดดูเผื่อช่วยอะไรได้บ้าง .... ง่ะ (;ノ゚Д゚)ノ

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

  1. การคาดเดาเหตุการณ์ความผิดพลาดที่อาจเกิดขึ้น
  2. ในกรณีที่เกิดความผิดพลาดที่เกินจะคาดเดา

คราวนี้ลองมาดูในรายละเอียดกันว่าแต่ละประเด็นมีอะไรน่าสนใจกันบ้าง

คาดเดาเหตุการณ์ความผิดพลาดที่อาจเกิดขึ้น

วิธีนี้ต้องใช้ประสบการณ์อันยาวนานและความสามารถเฉพาะตัวในการ "เดา" อย่างมีหลักการตลอดการทำงานของโปรแกรม เช่น

  • การคำนวณตัวเลข เช่น การ "หาร" สิ่งที่ต้องระวังคือ ตัวหารเป็น 0 หรือการนำข้อมูลที่เป็นตัวหนังสือมาคำนวณแบบคณิตศาสตร์ แต่ทำไม่ได้ เช่น "abc" * 1
  • ถ้ามีการเปิดไฟล์เพื่ออ่านข้อมูล ไฟล์ที่ต้องการอาจไม่เจอ ไฟล์ที่ระบุไม่ใช่ไฟล์ที่ต้องการ รูปแบบไฟล์ผิดพลาด
  • รับค่าข้อมูลจากผู้ใช้งาน อาจได้ข้อมูลผิดประเภท ช่วงข้อมูลเกินกว่าที่ต้องการ
  • ในการทำงานข้ามรุ่นโปรแกรม เก่า-ใหม่ รูปแบบการบันทึกและคำสั่งที่อาจก่อให้เกิดผิดพลาดในการทำงาน เช่น เขียนไฟล์ที่โปรแกรมรุ่นเก่าไม่รองรับ
  • การเชื่อมต่อฐานข้อมูล ต้องเตรียมใจ...เพียบเลย เช่น 
    • ต่อไม่ติด จากความผิดพลาดของไดร์เวอร์หรือตัวโปรแกรม
    • เน็ตเวิร์คไม่มี 
    • ชื่อและรหัสผ่านไม่ถูกต้อง 
    • หรือฐานข้อมูลพังอ่านไม่ได้ table ที่ต้องการไม่มี

การจัดการโดยการ "ดัก" เหตุการณ์ที่อาจเกิดขึ้นได้ และแจ้งให้ผู้ใช้งานทราบถึงความผิดพลาดดังกล่าวก่อนที่โปรแกรมจะเดี้ยง ยิ่งรองรับมาก ผู้ใช้งานใช้ง่าย ภาระก็จะตกของคนเขียนโปรแกรมเต็ม ๆ

ข้อดีที่โปรแกรมสามารถ "ดัก" ความผิดพลาดเหล่านี้ได้นั้นคือ โปรแกรมจะสามารถทำงานต่อหรือหยุดทำงานในแบบที่หาสาเหตุของปัญหา เพื่อหาทางลงแบบนิ่ม ๆ ได้ และทำให้คนเขียนโปรแกรมสามารถแนะนำวิธีการแก้ไขปัญหากับผู้ใช้งานได้อย่างถูกต้องตรงจุด แต่ก็แลกด้วยการใช้พลังมหาศาลในการเขียนในส่วนนี้ขึ้นมาแต่...ในชีวิตจริงไม่มีใครที่จะเขียนหรือคิดได้ครอบคลุมทุกเงื่อนไขเพราะทรัพยากรมีจำกัด (เวลา พลังชีวิต และ "ค่าจ้าง")

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

ทำตาราง 3 คอลัมน์ให้กรอกข้อมูลสินค้า จำนวน และราคา
กำหนดชื่อว่า table_product เพื่อใช้อ้างอิงในแมโคร

ลำดับการทำงานของแมโครก็ง่าย ๆ ดังนี้

  1. ทำการอ่านค่าสินค้าใน table_product ไปที่ละแถว จนครบทุกแถว
  2. คำนวณราคาต่อหน่วย ราคา / จำนวน
  3. แสดงชื่อสินค้าที่ราคาต่อหน่วยถูกที่สุด

ลองเขียนเป็น VBA ตามขั้นตอนที่ต้องการ โดยตั้งชื่อว่า Sub Demo1()

Sub Demo1()
	Dim rTable As Range
	Set rTable = Names("table_product").RefersToRange

	'หาจำนวนแถวที่ใช้งาน
	Dim maxRow As Long
	maxRow = rTable.Parent.UsedRange.Rows.Count

	Dim nRow As Long, nRowResult As Long
	Dim nLowPrice As Long, nPrice As Long

	nLowPrice = 2147483647 'กำหนดค่าเริ่มต้นของราคาที่ใช้เปรียบเทียบไว้สูงที่สุด

	'อ่านข้อมูลตั้งแต่แถวที่ 2 จนถึงแถวสุดท้าย
	For nRow = 2 To maxRow
		With rTable.Rows(nRow)
			'คำนวณเฉพาะแถวที่มีชื่อสินค้า
			If .Cells(1).Value <> "" Then
				nPrice = .Cells(3).Value / .Cells(2).Value 'คำนวนราคาต่อหน่วยจากคอลัมน์ที่ #3 หาร #2
				If nPrice < nLowPrice Then
					nLowPrice = nPrice
					nRowResult = nRow
				End If
			End If
		End With
	Next

	'แสดงข้อความผลที่ได้
	MsgBox "ราคาสินค้าต่อหน่วยต่ำที่สุดคือ " & rTable.Cells(nRowResult, 1).Value

End Sub

ลองมาปู้ยี่ปู้ยำโปรแกรมกันโดยลองใส่ข้อมูลไม่ครบ แถวที่ 4 ไม่ใส่จำนวนชิ้น เมื่อ Run แมโคร ก็จะเจอ

ผ่าง!!! Division by zero

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

Type mismatch อะไร mismatch จะรู้ไหม ( ゚ Д゚)

หรือถ้าผู้ใช้งานดันทะลึ่งไปลบ Name ที่ตั้งเอาไว้ ไม่ว่าจะตั้งใจหรือเพราะรู้เท่าไม่ถึงการณ์ก็แล้วแต่ พอ Run แมโครก็จะ Error อีกแล้ว

อ๊ากกก...ลบ Name ทิ้งทำม๊ายยยย (+_+)

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

การแก้ไขปัญหาดังกล่าว สามารถใช้วิธีเขียนคำสั่ง If เพื่อตรวจสอบค่าก่อนนำไปใช้งาน เพื่อให้แน่ใจว่าจะไม่เกิด Run-time error มาดูคำสั่งที่เขียนเพิ่มเติมเพื่อดักปัญหาต่าง ๆ ที่ลองยกตัวอย่างมา

	
Sub Demo2()
	Dim rTable As Range
	
	On Error Resume Next
		Set rTable = Names("table_product").RefersToRange
	On Error GoTo 0
	
	If rTable Is Nothing Then
		MsgBox "ไม่พบ Name 'table_product' ในแผ่นงาน โปรดตรวจสอบ Name Manager", vbCritical
		Exit Sub
	End If


	'หาจำนวนแถวที่ใช้งาน
	Dim maxRow As Long
	maxRow = rTable.Parent.UsedRange.Rows.Count

	Dim nRow As Long, nRowResult As Long
	Dim nLowPrice As Long, nPrice As Long

	nLowPrice = 2147483647 'กำหนดค่าเริ่มต้นของราคาที่ใช้เปรียบเทียบไว้สูงที่สุด

	'อ่านข้อมูลตั้งแต่แถวที่ 2 จนถึงแถวสุดท้าย
	For nRow = 2 To maxRow
		With rTable.Rows(nRow)
			'คำนวณเฉพาะแถวที่มีชื่อสินค้า
			If .Cells(1).Value <> "" Then

				If TypeName(.Cells(2).Value) <> "Double" Or TypeName(.Cells(3).Value) <> "Double" Then
				  MsgBox "โปรดระบุค่าเป็นตัวเลขเท่านั้น โปรดตรวจสอบแถวที่ " & nRow, vbCritical
				  Exit Sub
				End If

				If .Cells(2).Value <= 0 Then
				  MsgBox "จำนวนชิ้นมีค่าน้อยกว่าหรือเท่ากับ 0 ในแถวที่ " & nRow, vbCritical
				  Exit Sub
				End If

				nPrice = .Cells(3).Value / .Cells(2).Value 'คำนวนราคาต่อหน่วยจากคอลัมน์ที่ #3 หาร #2
				If nPrice < nLowPrice Then
					nLowPrice = nPrice
					nRowResult = nRow
				End If

			End If
		End With
	Next

	'แสดงข้อความผลที่ได้
	MsgBox "ราคาสินค้าต่อหน่วยต่ำที่สุดคือ " & rTable.Cells(nRowResult, 1).Value
End Sub	

จาก Sub Demo2() มีการปรับปรุงโดยการเพิ่มส่วนในการตรวจสอบค่าต่าง ๆ ก่อนนำไปใช้งาน หรือข้อมูลที่ได้อาจมีปัญหาจะทำการขึ้นข้อความเตือนและหยุดการทำงาน เพื่อให้ผู้ใช้กลับไปแก้ไขให้ถูกต้องก่อน จะเห็นว่าจำนวนบรรทัดของเดิม 30 เพิ่มเป็น 50 ก็กะคร่าว ๆ ว่าในส่วนของตรวจสอบความผิดพลาดเกือบ 30-50% ของงานที่ทำเลยก็ได้ ความจำเป็นในการเพิ่มและความถี่ในการตรวจสอบก็อยู่ที่ว่าต้องการให้โปรแกรมมันทนทานต่อความผิดพลาดได้แค่ไหน ถ้าขณะที่ใช้งานโปรแกรมอยู่ใกล้ ๆ สามารถเดินไปดูได้ ก็ไม่จำเป็นต้องลงทุนอะไรมาก แต่ถ้าหากเกิดปัญหาจะแก้ไขหรือตามดูแลลำบากก็ต้องจัดกันเต็ม ๆ

คราวนี้ไม่มึนล่ะ เพราะบอกเป็นภาษามนุษย์

แม้ว่าจะเขียนคำสั่งเพิ่มเข้าไปกันโน้นกันนี่แล้วแต่ก็ใช่ว่าจะกันได้ทุกเงื่อนไข อาจมีเงื่อนไขที่คาดไม่ถึงเกิดขึ้นได้อีกใครจะไปรู้ ดังนั้นจึงต้องมีการรับมือกับปัญหาที่เกินที่จะคาดเดาเอาไว้ด้วย มิฉะนั้นก็จะต้องเจอ Run-time error ที่ผู้ใช้งานอ่านแล้วไม่รู้เรื่องอยู่ดี

ในกรณีที่เกิดความผิดพลาดที่เกินจะคาดเดา

ใน VBA จะมีคำสั่ง On Error เพื่อช่วยในการจัดการกับ Error ต่าง ๆ ที่อาจเกิดขึ้น โดยหากเกิด Error ขึ้นแทนที่มันแสดง Run-time error ที่อ่านแล้วไม่เข้าใจ มันจะส่งต่อปัญหาดังกล่าวไปในส่วนที่เขียนขึ้นเพื่อจัดการปัญหาดังกล่าวเอง โครงสร้างโค้ดสำหรับจัดการ Error จะดังนี้

Sub Demo3()
	On Error GoTo handle_error

	'your code here
	'your code here
line_x:
	'your code here
  
	Exit Sub

handle_error:
	'handle error here
	'.................

	'go back to continue work
	Resume Next
	'or
	Resume line_x

	'if don't to continue then
	Exit Sub

End Sub

จากตัวอย่างใน Demo3 หากโปรแกรมเกิด Run-time error ขึ้น มันจะวิ่งไปที่บรรทัดที่ 11 ตามคำสั่ง On Error GoTo handle_error ผู้เขียนโค้ดจำเป็นต้องจัดการกับ Error ที่เกิดขึ้นซึ่งจะระบุไว้ใน object Err เช่น

  • ถ้าเกิด Division by zero จะได้ Err.Number เท่ากับ 11
  • ถ้าเกิด Type mismatch จะได้ Err.Number เท่ากับ 13

ก็เขียนจัดการกันไปว่า Err.Number เป็นค่าอะไร แล้วจะทำอย่างไรกับมันดี จะแจ้งปัญหาให้ผู้ใช้ทราบว่าเกิดอะไร และหลังจากนั้นจะให้ทำอะไรต่อ ซึ่งตัวเลือกตรงนี้มี 3 ตัวเลือกคือ

  • Resume Next กลับไปทำงานต่อที่คำสั่งที่อยู่ต่อไปจากที่ Error
  • Resume [line] กลับไปทำงานต่อที่ตำแหน่งบรรทัด [line] ที่ระบุ
  • Exit Sub จบการทำงาน ไม่สามารถทำงานต่อได้ (ถ้าอยู่ใน Function หรือ Property ก็ใช้ Exit Function หรือ Exit Property ให้ถูกต้อง)

สำหรับวิธีการใช้ On Error ในแมโคร สามารถดูรายละเอียดเพิ่มเติมที่เว็บไซต์ http://support.microsoft.com/kb/141571/EN-US ครับ ส่วนข้อมูลอ้างอิงอื่น ๆ เกี่ยวกับ Error Code

สำหรับเทคนิคที่ผมแนะนำคือ ควรมีตัวแปรแบบ Global สำหรับ module นั้น ๆ เพื่อช่วยบันทึกชื่อ Sub หรือ Function ที่ทำงานอยู่แล้วเกิด Error ขึ้น เพราะว่าใน VBA ค่า Err.Source จะระบุไว้หยาบมากคือบอกที่ระดับ Project ไหนที่เกิด Error

หากผู้ใช้งานเป็นระดับธรรมดา จับภาพหน้าจอไม่เป็น เจอภาษาอังกฤษแล้วรีบปิดหนี เจอแบบนี้แนะนำว่าให้เขียน log file เอาไว้ว่าเกิด Error อะไร แล้วให้ผู้ใช้ส่งไฟล์ดังกล่าวกลับมาให้ดูเพื่อวิเคราะห์ปัญหาที่เกิดขึ้น ตัวอย่างโปรแกรมแสดงใน Sub Demo4()

Sub Demo4()
	Dim currentModule As String
	currentModule = "Demo4()"
	On Error GoTo handle_error

	Debug.Print 1 / 0 'make some error

	Exit Sub

handle_error:
	Dim fileNumber As Long
	fileNumber = FreeFile
	Open ThisWorkbook.Path & "\error_log.txt" For Append As #fileNumber

	Print #fileNumber, "Date: ", Now()
	Print #fileNumber, "Module: ", currentModule
	Print #fileNumber, "Err.Source: ", Err.Source
	Print #fileNumber, "Err.Number: ", Err.Number
	Print #fileNumber, "Err.Description: ", Err.Description
	Print #fileNumber, ""

	Close #fileNumber

	MsgBox "พบความผิดพลาดในโปรแกรม " & _ 
		"โปรดส่งข้อมูลในไฟล์ error_log.txt ให้กับผู้พัฒนาโปรแกรม", vbCritical
	Shell "notepad.exe """ & ThisWorkbook.Path & "\error_log.txt" & """"

	Exit Sub

End Sub
เมื่อ Run โปรแกรม จะเกิด Error โปรแกรมจะแจ้งปัญหาและเปิดไฟล์ error_log.txt

อีกเทคนิคคือ ในกรณีที่โปรแกรมมีการเรียก Sub หลัก แล้ว Sub หลักไปเรียก Sub ย่อย ๆ ก็ให้วางคำสั่ง On Error ไว้ที่ Sub หลักเท่านั้น เมื่อเกิด Error ที่ Sub ย่อย ๆ ก็จะเด้งกลับมาที่ Sub หลักเพื่อจัดการ Error ทั้งหมดก็ได้ แต่วิธีนี้มีข้อจำกัดคือ พอกลับมาที่ Sub หลักแล้วจะไม่กลับไปทำที่ Sub ย่อยต่อ ดั้งนั้นไม่มีวิธีการตายตัวอยู่ที่การออกแบบ แล้วแต่ทรัพยากรจะอำนวยครับ

เฉพาะ Sub Main เท่านั้นที่มี On Error เมื่อเกิด Error ที่ Sub X จะไม่กลับไปที่ Sub X ต่อ

สุดท้ายก่อนจบประเด็นนี้ หากใช้ On Error Resume Next อย่าลืมยกเลิกด้วย On Error Goto 0 ด้วยเสมอ มิฉะนั้นหากโปรแกรมผิดพลาดขึ้นก็จะเงียบ และหาสาเหตุของปัญหาไม่เจอ อันตรายมากครับ

ปล.ตอนแรกตั้งใจจะเขียนให้สั้น ๆ แต่คงลดได้สุด ๆ แค่นี้ นี่ก็ยาวมาเกินที่คิดไว้เยอะเลย