ข้าม​ไป​ยัง​เนื้อหา

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 ผิดพลาด]

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");

อีก​รูปแบบ​ที่​พบ​บ่อย​คือ 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 ที่​พร้อม​ใช้งาน​เต็ม​รูปแบบ​ตั้งแต่​แรก​ก็​เป็น​อีก​ทาง​เลือก​ที่​ช่วย​ได้​เช่น​กัน