Required Setup/Teardown
ผู้เรียกต้องรัน setup/cleanup เองก่อนและหลังใช้งาน
กลิ่นนี้คืออะไร
หัวข้อที่มีชื่อว่า “กลิ่นนี้คืออะไร”Required Setup/Teardown เกิดเมื่อ ผู้เรียกต้องรัน code setup หรือ cleanup ที่จำเป็นเองด้วยมือ ก่อนและหลังใช้ class หรือ method แทนที่ class จะจัดการวงจรชีวิต (lifecycle) ของตัวเอง ความรับผิดชอบถูกผลักออกไปนอกขอบเขตของ class กลายเป็นภาระของทุกจุดที่เรียกใช้
รูปแบบทั่วไปคือ class ที่มี method อย่าง Open / Initialize ที่ต้องเรียกก่อนใช้งานจริง และ Close / Cleanup ที่ต้องเรียกหลังใช้เสร็จ — ถ้าผู้เรียกลืมขั้นตอนใดขั้นตอนหนึ่ง โปรแกรมอาจ compile ผ่านสบาย ๆ แต่พังตอนรันจริง เพราะ compiler ไม่มีทางบังคับลำดับการเรียก method ให้ถูกต้องได้
ตามที่ luzkan’s Code Smells catalog อธิบายไว้ ต้นตอมักมาจาก “การขาดความเชื่อมโยงภายใน (lack of cohesion)” — function การทำงานบางส่วนถูกดึงออกไปอยู่นอก class ระหว่างพัฒนา และนักพัฒนาลืมพิจารณาว่า class ควรใช้ function เหล่านั้น “ภายในตัวมันเอง” ผลคือ class ไม่สามารถถูกใช้ซ้ำได้อย่างสมบูรณ์ในตัวมันเอง — ต้องพ่วง code ภายนอกขอบเขตของมันเสมอ
วิธีสังเกต
หัวข้อที่มีชื่อว่า “วิธีสังเกต”- ชื่อ method ที่บอกใบ้ถึงลำดับการเรียก เช่น
Initialize,Setup,Open,Begin,Startคู่กับCleanup,Teardown,Close,End,Disposeที่ ไม่ได้ถูกบังคับด้วยภาษา - คอมเมนต์หรือเอกสารที่เขียนกำกับว่า “ต้องเรียก X ก่อนใช้งาน” หรือ “อย่าลืมเรียก Y หลังใช้เสร็จ” — เอกสารแบบนี้คือสัญญาณว่า compiler ไม่ได้ช่วยบังคับ
- code โยน exception ใน runtime เมื่อเรียก method หลักโดยไม่ผ่านขั้นตอน setup มาก่อน (เช่น
InvalidOperationException: Call Initialize first) - ทุกจุดที่ใช้ class มี code setup/teardown ซ้ำ ๆ กัน — สัญญาณของ Duplicate Code ที่เกิดจากภาระซึ่งควรอยู่ใน class เดียว
- test suite ที่เต็มไปด้วย
[SetUp]/[TearDown]ยาวเหยียดเพื่อเตรียม object ให้อยู่ในสถานะที่ใช้งานได้ ก่อนจะทดสอบ behavior จริง - resource leak (connection ค้าง, file handle ไม่ถูกปิด, lock ไม่ถูกปลด) ที่เกิดขึ้นเป็นระยะเพราะบางเส้นทาง code ลืมเรียก cleanup โดยเฉพาะเวลามี exception cut กลางทาง
ทำไมถึงเป็นปัญหา
หัวข้อที่มีชื่อว่า “ทำไมถึงเป็นปัญหา”ปัญหาหลักคือ Temporal Coupling — object ต้องถูกใช้งานตามลำดับที่กำหนดไว้ (setup → use → teardown) แต่ไม่มีกลไกใดบังคับลำดับนั้น Mark Seemann อธิบายไว้ในบทความเรื่อง Design Smell: Temporal Coupling ว่านี่คือการละเมิด encapsulation ประเภทหนึ่ง เพราะ object ถูกปล่อยให้อยู่ใน สถานะที่ไม่ถูกต้อง (invalid state) ได้ตลอดเวลาที่ยังไม่ได้ setup — code จะ compile ผ่านเสมอ แต่พังเฉพาะตอนรัน ซึ่งขัดกับหลัก Fail Fast ที่อยากให้ปัญหาโผล่เร็วที่สุดเท่าที่จะทำได้ ไม่ใช่รอไปเจอตอน production
ผลกระทบที่ตามมา:
- ผู้เรียกแบกภาระที่ควรเป็นของ class — ขัดกับหลัก Encapsulation ที่ว่า class ควรซ่อนรายละเอียดการจัดการสถานะภายในของตัวเอง ไม่ใช่เปิดเผยให้โลกภายนอกต้องมาดูแลแทน
- การใช้งานที่ถูกต้องพึ่งพา convention และความจำ ไม่ใช่กลไกที่ compiler ตรวจสอบได้ นี่คือรูปแบบหนึ่งของ Hidden Dependencies — ผู้เรียกต้อง “รู้” ว่าต้องเรียกอะไรก่อนอะไร ทั้งที่ signature ของ type ไม่ได้บอกอะไรเลย
- code setup/teardown ถูกก็อปวางซ้ำในทุกจุดที่ใช้งาน เมื่อวันหนึ่งขั้นตอน setup เปลี่ยน ทุกจุดต้องแก้ตาม — สุ่มเสี่ยงหลุดบางจุด
- ขัดกับ Principle of Least Astonishment — ผู้ใช้ class คนใหม่คาดหวังว่าสร้าง object แล้วเรียกใช้ได้เลย การต้องไปอ่านเอกสารเพื่อรู้ลำดับการเรียกที่ “ถูกต้อง” เป็นเรื่องน่าประหลาดใจและมักถูกมองข้าม
- cleanup มักถูกลืมง่ายกว่าลิม เพราะอยู่ท้ายสุดของ flow และมักไม่ถูกเรียกเมื่อมี exception เกิดขึ้นกลางทาง ถ้าไม่ใช้กลไกที่ภาษารองรับ (เช่น
try/finallyหรือusing) resource ก็รั่วได้ง่าย ๆ
flowchart LR Create[สร้าง object] --> Setup[เรียก Setup ด้วยมือ] Setup --> Use[ใช้งาน object] Use --> Teardown[เรียก Teardown ด้วยมือ] Teardown --> Leak[ถ้าลืมขั้นตอนใด เกิด resource รั่ว หรือ state ผิดพลาด]
ตัวอย่างและการ refactor
หัวข้อที่มีชื่อว่า “ตัวอย่างและการ refactor”กรณีที่ 1: resource ต้องเปิด-ปิดเอง
หัวข้อที่มีชื่อว่า “กรณีที่ 1: resource ต้องเปิด-ปิดเอง”code สมกลิ่นด้านล่างบังคับให้ผู้เรียก Open ก่อนใช้ และ Close เองหลังใช้เสร็จ — ถ้าเกิด exception ระหว่างทาง Close จะไม่ถูกเรียกเลย
public class ReportExporter{ private FileStream? _stream;
public void Open(string path) { _stream = new FileStream(path, FileMode.Create); }
public void Write(string line) { // ถ้าไม่เคยเรียก Open มาก่อน จะพังตอนรันเท่านั้น var bytes = Encoding.UTF8.GetBytes(line + Environment.NewLine); _stream!.Write(bytes, 0, bytes.Length); }
public void Close() { _stream?.Dispose(); }}
// ผู้เรียกต้องจำลำดับเอง และห่อ try/finally เองทุกจุดvar exporter = new ReportExporter();exporter.Open("report.txt");try{ exporter.Write("แถวที่ 1");}finally{ exporter.Close(); // ลืมบรรทัดนี้บรรทัดเดียว file handle ค้างทันที}refactor ด้วยการ ห่อ lifecycle ไว้ในตัว class เอง (RAII ผ่าน IDisposable) — ให้ constructor ทำหน้าที่ “acquire” ทรัพยากร และ Dispose ทำหน้าที่ “release” แล้วให้ using ของภาษาบังคับลำดับแทนความจำของมนุษย์:
public sealed class ReportExporter : IDisposable{ private readonly FileStream _stream;
// สร้างเสร็จ = พร้อมใช้งานทันที ไม่มีสถานะกลาง ๆ ที่ยังใช้ไม่ได้ public ReportExporter(string path) { _stream = new FileStream(path, FileMode.Create); }
public void Write(string line) { var bytes = Encoding.UTF8.GetBytes(line + Environment.NewLine); _stream.Write(bytes, 0, bytes.Length); }
public void Dispose() { _stream.Dispose(); }}
// using บังคับให้ Dispose ถูกเรียกเสมอ แม้เกิด exception กลางทางusing var exporter = new ReportExporter("report.txt");exporter.Write("แถวที่ 1");กรณีที่ 2: ต้อง Initialize ก่อนเรียก method หลัก
หัวข้อที่มีชื่อว่า “กรณีที่ 2: ต้อง Initialize ก่อนเรียก method หลัก”อีกรูปแบบที่พบบ่อยคือ method Initialize ที่ต้องเรียกก่อน method หลักเสมอ แต่ compiler ไม่ได้บังคับลำดับนี้เลย
public class Radio{ private string? _stationName;
public void Initialize(string stationName) { _stationName = stationName; }
public void Broadcast(string message) { // ถ้าไม่เคยเรียก Initialize จะ throw ตอนรันเท่านั้น if (_stationName is null) throw new InvalidOperationException("ต้องเรียก Initialize ก่อนใช้ Broadcast");
Console.WriteLine($"[{_stationName}] {message}"); }}refactor ด้วยการ ย้าย initialization เข้า constructor เพื่อกำจัด temporal coupling — เมื่อสร้าง object สำเร็จ มันต้องอยู่ในสถานะที่ใช้งานได้เสมอ ไม่มีคำว่า “ยังไม่พร้อม”:
public class Radio{ private readonly string _stationName;
// ไม่มีทางสร้าง Radio ที่ยังไม่มีชื่อสถานีได้เลย public Radio(string stationName) { _stationName = stationName; }
public void Broadcast(string message) { Console.WriteLine($"[{_stationName}] {message}"); }}
var radio = new Radio("Kaen FM");radio.Broadcast("สวัสดีผู้ฟัง"); // ใช้งานได้ทันที ไม่มีขั้นตอนแฝงทั้งสองกรณีใช้แนวคิดเดียวกัน: ผลักภาระ lifecycle เข้าไปอยู่ ภายใน class แล้วให้กลไกที่ compiler บังคับได้ (constructor ที่ไม่มี overload ว่างเปล่า, IDisposable คู่กับ using) แทนที่ convention ที่มนุษย์ต้องจำเอง หากทรัพยากรมีหลายขั้นตอนซับซ้อนกว่านี้ การใช้ Factory Method เพื่อคืน object ที่พร้อมใช้งานเต็มรูปแบบตั้งแต่แรกก็เป็นอีกทางเลือกที่ช่วยได้เช่นกัน
ที่เกี่ยวข้อง
หัวข้อที่มีชื่อว่า “ที่เกี่ยวข้อง”- Encapsulation
- Hidden Temporal Coupling
- Principle of Least Astonishment
- Hidden Dependencies
- Fail Fast