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

Temporary Field

field ที่​มี​ค่า​เฉพาะ​บาง​สถานการณ์ นอก​นั้น​เป็น null/ไร้​ความหมาย

Temporary Field เกิด​เมื่อ class มี field ที่​ได้​รับ​ค่า​และ​มี​ความหมาย​เฉพาะ​บาง​สถานการณ์​เท่านั้น — โดย​มาก​คือ​ระหว่าง method ใด method หนึ่ง​หรือ​ลำดับ​การ​เรียก method เฉพาะ — ส่วน​เวลา​อื่น​มัน​เป็น null, ศูนย์ หรือ​ค่า​ว่าง​ที่​ไร้​ความหมาย field แบบ​นี้​ไม่​ได้​มี​อยู่​เพราะ​เป็น​คุณสมบัติ​ที่​เสถียร​ของ object แต่​เกิด​จาก​การ​หา​ทาง​ลัด: แทนที่​จะ​ส่ง​ค่า​หลาย​ตัว​ผ่าน parameter ของ method (ซึ่ง​จะ​ทำให้ signature ยาว​และ​กลาย​เป็น Long Parameter List) โปรแกรมเมอร์​เลือก​ประกาศ​เป็น instance field ของ class แทน เพื่อ​ให้ method หลาย​ตัว​ใน algorithm เดียวกัน​เข้าถึง​ค่า​เหล่า​นั้น​ได้​โดย​ไม่​ต้อง​ส่ง​ผ่าน parameter

ตัวอย่าง​คลาสสิก​จาก​หนังสือ Refactoring ของ Martin Fowler คือ class Estimator ที่​มี field อย่าง duration, average, standardDeviation ซึ่ง​ถูก​ตั้ง​ค่า​เฉพาะ​ตอน​เรียก CalculateAverage() แล้ว​ใช้​ต่อ​ใน CalculateStandardDeviation() — นอก​บริบท​การ​คำนวณ​นี้ field เหล่า​นั้น​ไม่มี​ความหมาย​อะไร​เลย

กลิ่น​นี้​จัด​อยู่​ใน​กลุ่ม Object-Orientation Abusers (การ​ใช้​แนวคิด OOP ไม่​ถูก​วิธี) เพราะ​มัน​ขัด​กับ​สัญญา​โดย​นัย​ของ class: ผู้​ใช้ class คาด​หวัง​ว่า field ทุก​ตัว​ที่​ประกาศ​ไว้​ควร​มี​ความหมาย​ตลอด​ช่วง​ชีวิต​ของ object ไม่ใช่​แค่​ใน​หน้าต่าง​เวลา​แคบ ๆ ระหว่าง method สอง​สาม​ตัว

  • field ถูก​กำหนด​ค่า (assign) ใน method หนึ่ง แล้ว​ถูก​อ่าน​ใช้​ใน​อีก method หนึ่ง​เท่านั้น ไม่มี​ที่​อื่น​ใน class อ้าง​ถึง​มัน​เลย
  • ก่อน​เรียก method ที่ “เติม​ค่า” ให้ field นั้น ค่า​ของ​มัน​จะ​เป็น null, default, ศูนย์ หรือ empty string เสมอ
  • มี logic ตรวจสอบ if (field != null) หรือ if (field.HasValue) กระจาย​อยู่​หลาย​จุด เพื่อ​เช็ค​ว่า “ตอน​นี้ field ถูก​เติม​ค่า​แล้ว​หรือ​ยัง”
  • ชื่อ field บอกใบ้​ถึง​การ​เป็น​ค่า​ชั่วคราว​ของ algorithm เช่น _tempResult, _currentBatch, _workingSet
  • เปลี่ยน​ลำดับ​การ​เรียก method สอง​ตัว​แล้ว compile ผ่าน​ปกติ แต่​ผลลัพธ์​กลับ​ผิด (เพราะ field ที่​ควร​ถูก​เติม​ค่า​ก่อนหน้า​ยัง​ไม่​ถูก​เติม)
  • class ไม่ thread-safe เพราะ state ชั่วคราว​ถูก​เก็บ​ใน instance field ที่​ใช้​ร่วม​กัน​ระหว่าง​การ​เรียก​พร้อม​กัน​หลาย​ครั้ง
  • method ใน unit test ต้อง​เรียก method อื่น​ก่อน​เสมอ​เพื่อ “เตรียม” field ให้​พร้อม ก่อน​จะ​ทดสอบ method ที่​ต้องการ​จริง ๆ
  1. ทำลาย​ความ​คาด​หวัง​พื้นฐาน​ของ encapsulation — field ของ object ควร​สะท้อน state ที่​มี​ความหมาย​ตลอด​เวลา ไม่ใช่​มี​ความหมาย​เฉพาะ​บาง​ช่วง การ​อ่าน code จึง​ต้อง​ไล่​ตาม​ลำดับ​การ​เรียก method เพื่อ​รู้​ว่า field ไหน “พร้อม​ใช้” ตอน​ไหน ซึ่ง​เพิ่ม​ภาระ​ทาง​สมอง​อย่าง​มาก
  2. สร้าง​การ​ต่อ​เชื่อม​ทาง​เวลา (temporal coupling) — method ต้อง​ถูก​เรียก​ตาม​ลำดับ​ที่​ถูกต้อง​เท่านั้น compiler ไม่​ช่วย​บังคับ​สิ่ง​นี้ ถ้า​สลับ​ลำดับ code compile ผ่าน​แต่​ให้​ผลลัพธ์​ผิด​แบบ​เงียบ ๆ (silent failure) ดู​กลิ่น​ที่​เกี่ยวข้อง​คือ Temporal Coupling
  3. ทำให้ class ไม่ thread-safe โดย​ไม่รู้ตัว — ถ้า instance เดียวกัน​ถูก​ใช้​ซ้ำ​จาก​หลาย thread state ชั่วคราว​ใน field จะ​ถูก​เขียน​ทับ​กัน​ข้าม​การ​เรียก เกิด race condition ที่ debug ยาก
  4. ขยาย surface การ​ทดสอบ​ผิด​จุด — เพื่อ unit test method เดียว ต้อง​เรียก method อื่น​ก่อน​เพื่อ​เซ็ต field ให้​ถูกต้อง ทำให้ test เปราะ​และ​ผูก​กับ​รายละเอียด implementation แทนที่​จะ​ทดสอบ​พฤติกรรม​ที่​ต้องการ
  5. ทำให้ class รับผิดชอบ​เกิน​ตัว — field ชั่วคราว​มัก​เป็น​สัญญาณ​ว่า​มี “อีก​หนึ่ง​แนวคิด domain” ซ่อน​อยู่​ใน class นี้ (เช่น การ​คำนวณ​ทาง​สถิติ, การ​ประมวล​ผล​เป็น batch) ที่​สมควร​แยก​เป็น class ของ​ตัวเอง การ​ปล่อย​ไว้​แบบ​นี้​ทำให้ Single Responsibility Principle ถูก​ละเมิด​ไป​ด้วย

code ต่อ​ไป​นี้​จำลอง​การ​คำนวณ​ค่า​สถิติ​ของ​ระยะ​เวลา​การ​ทำงาน (durations) คล้าย​ตัวอย่าง​คลาสสิก​ของ Fowler: _average และ _standardDeviation เป็น temporary field ที่​มี​ความหมาย​เฉพาะ​ระหว่าง​เรียก CalculateAverage() ตาม​ด้วย CalculateStandardDeviation() เท่านั้น

// ก่อน refactor: temporary field ที่มีความหมายเฉพาะบางลำดับการเรียก
public class DurationEstimator
{
private readonly List<double> _durations;
// field เหล่านี้ไม่มีความหมายจนกว่าจะเรียก CalculateAverage() ก่อน
private double _average;
private double _standardDeviation;
public DurationEstimator(List<double> durations)
{
_durations = durations;
}
public double CalculateAverage()
{
_average = _durations.Sum() / _durations.Count;
return _average;
}
// ต้องเรียก CalculateAverage() มาก่อน ไม่งั้นได้ผลลัพธ์ผิด
// แต่ compiler ไม่บังคับลำดับนี้เลย
public double CalculateStandardDeviation()
{
var variance = _durations
.Select(d => Math.Pow(d - _average, 2))
.Sum() / _durations.Count;
_standardDeviation = Math.Sqrt(variance);
return _standardDeviation;
}
}

ปัญหา: สลับ​ลำดับ​เรียก CalculateStandardDeviation() ก่อน CalculateAverage() แล้ว code compile ผ่าน​สบาย ๆ แต่​ผลลัพธ์​ผิด​เงียบ ๆ เพราะ _average ยัง​เป็น 0

แยก temporary field และ logic ที่​เกี่ยวข้อง​ออก​เป็น class ใหม่​ที่​มีอายุ​สั้น สร้าง​ขึ้น​เฉพาะ​สำหรับ​การ​คำนวณ​ครั้ง​เดียว — เทคนิค​นี้​ตรง​กับ​สิ่ง​ที่ Fowler เรียก​ว่า Replace Method with Method Object

// หลัง refactor: Extract Class ดึง state ชั่วคราวออกเป็น class ของตัวเอง
public class DurationStatistics
{
private readonly IReadOnlyList<double> _durations;
public DurationStatistics(IReadOnlyList<double> durations)
{
_durations = durations;
Average = _durations.Sum() / _durations.Count;
StandardDeviation = CalculateStandardDeviation(Average);
}
// field เหล่านี้มีความหมายเสมอตลอดช่วงชีวิตของ object
// ไม่ขึ้นกับลำดับการเรียก method ใด ๆ
public double Average { get; }
public double StandardDeviation { get; }
private double CalculateStandardDeviation(double average)
{
var variance = _durations
.Select(d => Math.Pow(d - average, 2))
.Sum() / _durations.Count;
return Math.Sqrt(variance);
}
}
// การใช้งาน: ไม่มีลำดับการเรียกให้ทำผิดได้อีกต่อไป
var stats = new DurationStatistics(durations);
Console.WriteLine($"{stats.Average} / {stats.StandardDeviation}");

ผลลัพธ์​ของ​การ refactor: Average และ StandardDeviation ถูก​คำนวณ​ครบ​ใน constructor แล้ว​เปิด​เป็น read-only property จึง​ไม่มี​ทาง​ถูก​อ่าน​ก่อน​ที่​จะ​ถูก​คำนวณ ไม่มี temporal coupling และ instance ใหม่​แต่ละ​ครั้ง​ไม่ share state ข้าม thread

ถ้า​มี​ค่า​ชั่วคราว​แค่ 1-2 ตัว​และ​ไม่​ได้​ส่ง​ต่อ​กัน​หลาย method การ​ส่ง​เป็น parameter ตรง ๆ ก็​เพียงพอ ไม่​จำเป็น​ต้อง Extract Class ทั้ง class:

public double CalculateStandardDeviation(IReadOnlyList<double> durations, double average)
{
var variance = durations.Select(d => Math.Pow(d - average, 2)).Sum() / durations.Count;
return Math.Sqrt(variance);
}

เมื่อ temporary field มี​ไว้​เพื่อ​เลี่ยง null check ที่​กระจาย​ทั่ว class

หัวข้อ​ที่​มีชื่อ​ว่า “เมื่อ temporary field มี​ไว้​เพื่อ​เลี่ยง null check ที่​กระจาย​ทั่ว class”

อีก​กรณี​ที่​พบ​บ่อย​คือ field ถูก​เช็ค != null ก่อน​ใช้งาน​ใน​หลาย​จุด ทาง​แก้​ตาม​แนวทาง Introduce Null Object คือ​แทน​ค่า null ด้วย object ตัวแทน​ที่​มี​พฤติกรรม​เป็นกลาง (no-op) แทน เพื่อ​ลบ​เงื่อนไข​ตรวจสอบ​ที่​กระจาย​อยู่​ทั่ว class ออก​ไป

classDiagram
    class DurationEstimator {
        -durations
        -average
        -standardDeviation
        +CalculateAverage
        +CalculateStandardDeviation
    }
    note for DurationEstimator "average และ standardDeviation มีความหมายเฉพาะหลังเรียก method ตามลำดับ"

    class DurationStatistics {
        +Average
        +StandardDeviation
    }
    note for DurationStatistics "คำนวณครบใน constructor ไม่มี temporal coupling"

    DurationEstimator --> DurationStatistics : refactor เป็น
  • Encapsulation — หลักการ​ที่ Temporary Field ละเมิด เพราะ field ไม่​ได้​สะท้อน state ที่​มี​ความหมาย​เสมอ
  • Data Class — กลิ่น​ใกล้​เคียง​ที่ class มี field แต่​ไม่มี behavior ที่​ควบคุม​มัน​อย่าง​เหมาะสม
  • Long Parameter List — สาเหตุ​ตั้งต้น​ที่​มัก​ผลักดัน​ให้​เกิด Temporary Field แทน​การ​ส่ง parameter
  • Temporal Coupling — ผล​ข้าง​เคียง​หลัก​ของ Temporary Field คือ​การ​บังคับ​ลำดับ​การ​เรียก method ที่ compiler ไม่​ช่วย​ตรวจสอบ
  • Make Illegal States Unrepresentable — แนวทาง​ออกแบบ​ที่​ป้องกัน​ไม่​ให้ state ที่​ยัง​ไม่​พร้อม (field ว่าง) ถูก​สร้าง​ขึ้น​ได้​ตั้งแต่​แรก
  • Single Responsibility Principle — Extract Class ที่​ใช้​แก้​กลิ่น​นี้​มัก​เผย​ว่า​มี​ความ​รับผิดชอบ​ที่​สอง​ซ่อน​อยู่​ใน class เดิม