Tramp Data
ข้อมูลที่ถูกส่งผ่านหลาย method โดยที่ method กลางทางไม่ได้ใช้
กลิ่นนี้คืออะไร
หัวข้อที่มีชื่อว่า “กลิ่นนี้คืออะไร”Tramp Data คือกลิ่นที่ข้อมูลชิ้นหนึ่ง เดินทางผ่านหลาย method ในฐานะ parameter แต่ method กลางทางไม่ได้ใช้หรือสนใจมันเลย — หน้าที่เดียวของ method กลางทางคือรับ parameter นั้นมาแล้วส่งต่อไปให้ method ถัดไปเฉย ๆ ข้อมูลจึงกลายเป็น “คนพเนจร” (tramp) ที่ย้ายที่ไปเรื่อย ๆ โดยไม่มี “บ้าน” ที่แท้จริงใน code
ชื่อและแนวคิดของกลิ่นนี้มาจากวงการ code smell แบบ Steve McConnell (Code Complete) และถูกนำมาจัดหมวดใน DevIQ ควบคู่กับ Long Parameter List และ Message Chain ต่างจาก Message Chain ที่ปัญหาอยู่ที่การ “ไล่เรียก” ผ่านเชน (a.getB().getC().getD()) Tramp Data มองจากอีกมุมคือ “parameter” ที่ถูกส่งต่อกันเป็นทอด ๆ ผ่านลำดับการเรียก method (call chain) — สองกลิ่นนี้จึงมักเกิดคู่กันใน code เดียวกัน เพราะการไล่เรียกเชนบ่อยครั้งก็คือการ “แบก” ข้อมูลผ่าน method กลางทางนั่นเอง
วิธีสังเกต
หัวข้อที่มีชื่อว่า “วิธีสังเกต”- method มี parameter ที่ ไม่ได้ใช้ในตัวเอง เลย นอกจากส่งต่อให้ method ที่มันเรียกภายใน
- เวลาต้องเพิ่ม field ใหม่ในข้อมูลก้อนหนึ่ง ต้องแก้ signature ของ method หลายชั้นตลอดทาง ทั้งที่ method กลางทางไม่เคยแตะข้อมูลนั้น
- ชื่อ parameter ซ้ำ ๆ กันปรากฏใน signature ของ method หลายตัวติดกันเป็นสาย (A เรียก B เรียก C เรียก D โดย parameter เดิมไหลผ่านทุกจุด)
- เขียน unit test ให้ method กลางทางแล้วรู้สึกแปลก ๆ เพราะต้องสร้าง mock/stub ของข้อมูลที่ method นั้นไม่ได้ใช้จริง เพียงเพื่อให้ compile ผ่าน
- method utility หรือ helper กลายเป็น “ท่อ” (pipe) ที่มี parameter ยาวขึ้นเรื่อย ๆ ตามเวลา เพราะทุกครั้งที่ปลายทางต้องการข้อมูลเพิ่ม คนก็เพิ่ม parameter ผ่านทุกชั้น
ทำไมถึงเป็นปัญหา
หัวข้อที่มีชื่อว่า “ทำไมถึงเป็นปัญหา”Interface ไม่ตรงกับสิ่งที่ method ทำจริง — เมื่อ signature ของ method มี parameter ที่มันไม่แตะเลย มันโกหกผู้อ่านว่า method นี้ “เกี่ยวข้อง” กับข้อมูลนั้น ทั้งที่จริง ๆ ไม่ใช่ ทำให้ตีความเจตนาของ code ได้ยากขึ้น
ผูกกับ Law of Demeter — Tramp Data มักเป็นอาการของการละเมิด Law of Demeter: function ควรอยู่ใกล้ข้อมูลที่มันดำเนินการด้วย เมื่อข้อมูลต้องเดินทางผ่านชั้นที่ไม่เกี่ยวข้องเพื่อไปถึงจุดที่ใช้จริง แปลว่าโครงสร้าง class/module จัดวางความรับผิดชอบผิดที่
เปราะต่อการเปลี่ยนแปลง (Shotgun Surgery) — ถ้าข้อมูลที่แบกผ่านต้องเปลี่ยนรูปร่าง (เพิ่ม field, เปลี่ยนชนิด) ทุก method ตลอดทางต้องแก้ signature แม้จะไม่ได้ใช้ข้อมูลนั้นเลยก็ตาม ยิ่งเชนยาว ยิ่งแก้กระทบวงกว้าง
parameter ยาวตามเวลา — method กลางทางมักจบลงด้วย Long Parameter List เพราะทุกครั้งที่ปลายทางต้องการข้อมูลเพิ่ม วิธีที่ง่ายที่สุด (แต่ผิด) คือเพิ่ม parameter ผ่านทุกชั้นที่คั่นอยู่
ทดสอบยาก — method กลางทางที่ไม่ได้ใช้ข้อมูลแต่ต้องรับมันเข้ามา บังคับให้ test ของ method นั้นต้องสร้าง/mock ข้อมูลที่ไม่เกี่ยวข้องกับสิ่งที่กำลังทดสอบ เพิ่ม noise ให้ทั้ง production code และ test code
ตัวอย่างและการ refactor
หัวข้อที่มีชื่อว่า “ตัวอย่างและการ refactor”ตัวอย่างระบบออกใบแจ้งหนี้: discountRate และ currency ต้องเดินทางผ่าน ProcessOrder ไปยัง CalculateTotal แล้วไปยัง ApplyPricing ซึ่งเป็นจุดเดียวที่ใช้งานจริง — method กลางทางสองตัวแบก parameter โดยไม่แตะต้องมันเลย
// ก่อน — discountRate และ currency เป็น tramp data ที่ไหลผ่าน ProcessOrder และ CalculateTotalpublic class OrderService{ public decimal ProcessOrder(Order order, decimal discountRate, string currency) { // method นี้ไม่ได้ใช้ discountRate หรือ currency เลย แค่ส่งต่อ return CalculateTotal(order, discountRate, currency); }
private decimal CalculateTotal(Order order, decimal discountRate, string currency) { // method นี้ก็ไม่ได้ใช้ discountRate หรือ currency โดยตรง แค่ส่งต่ออีกที decimal subtotal = order.Items.Sum(i => i.Price * i.Quantity); return ApplyPricing(subtotal, discountRate, currency); }
private decimal ApplyPricing(decimal subtotal, decimal discountRate, string currency) { // จุดเดียวที่ใช้ discountRate และ currency จริง ๆ decimal discounted = subtotal * (1 - discountRate); return CurrencyConverter.Convert(discounted, currency); }}การ refactor ที่ 1 — Introduce Parameter Object: รวม discountRate และ currency (และข้อมูลราคาที่เกี่ยวข้องอื่น ๆ) เป็น class เดียวชื่อ PricingContext ลด noise ใน signature และเปิดทางให้เพิ่ม field ในอนาคตโดยไม่ต้องแก้ทุกชั้น
// หลัง — รวม parameter ที่เดินทางด้วยกันเป็น PricingContext เดียวpublic sealed record PricingContext(decimal DiscountRate, string Currency);
public class OrderService{ public decimal ProcessOrder(Order order, PricingContext pricing) { return CalculateTotal(order, pricing); }
private decimal CalculateTotal(Order order, PricingContext pricing) { decimal subtotal = order.Items.Sum(i => i.Price * i.Quantity); return ApplyPricing(subtotal, pricing); }
private decimal ApplyPricing(decimal subtotal, PricingContext pricing) { decimal discounted = subtotal * (1 - pricing.DiscountRate); return CurrencyConverter.Convert(discounted, pricing.Currency); }}การรวม parameter ช่วยลดความยาวของ signature แต่ ไม่ได้แก้ปัญหาที่ต้นเหตุ — ProcessOrder และ CalculateTotal ยังคงต้องแบก pricing ผ่านตัวเองอยู่ดี เพียงแค่แบกก้อนเดียวแทนที่จะแบกสองก้อน
การ refactor ที่ 2 — Move Method / มอบหมายให้ผู้ที่ “เป็นเจ้าของ” ข้อมูล: ถ้า pricing logic ทั้งหมดไม่เกี่ยวกับการไหลของ Order ผ่าน OrderService เลย ให้ย้าย pricing calculation ออกไปเป็น class ของมันเอง แล้วให้ OrderService เรียกใช้โดยตรงในจุดที่ต้องการ ไม่ต้องผ่าน method กลางทางที่ไม่เกี่ยวข้อง
// หลัง — PricingCalculator เป็นเจ้าของ pricing logic โดยตรง ไม่มีข้อมูลพเนจรอีกต่อไปpublic class PricingCalculator{ public decimal Calculate(decimal subtotal, PricingContext pricing) { decimal discounted = subtotal * (1 - pricing.DiscountRate); return CurrencyConverter.Convert(discounted, pricing.Currency); }}
public class OrderService{ private readonly PricingCalculator _pricingCalculator;
public OrderService(PricingCalculator pricingCalculator) { _pricingCalculator = pricingCalculator; }
public decimal ProcessOrder(Order order, PricingContext pricing) { decimal subtotal = order.Items.Sum(i => i.Price * i.Quantity); // เรียก pricing calculator ตรง ๆ ที่จุดที่ต้องการผลลัพธ์ ไม่ต้องมี method กลางทางแบกข้อมูลอีก return _pricingCalculator.Calculate(subtotal, pricing); }}ตอนนี้ pricing ถูกใช้ทันทีที่จุดที่ OrderService.ProcessOrder เรียก ไม่มีชั้นกลางทางที่แบกมันโดยไม่ใช้อีกต่อไป — และถ้าในอนาคต pricing logic ต้องการข้อมูลเพิ่ม ก็แก้แค่ PricingContext กับ PricingCalculator โดยไม่กระทบ OrderService
flowchart LR
Before["ก่อน: OrderService -> CalculateTotal -> ApplyPricing (แบก discountRate, currency ตลอดทาง)"]
After["หลัง: OrderService เรียก PricingCalculator ตรง ที่จุดใช้งานเดียว"]
Before -->|Move Method + Introduce Parameter Object| After
หลักการเลือกวิธีแก้: ถ้าข้อมูลที่แบกผ่านเป็นกลุ่มก้อนเดียวกันเสมอ ให้เริ่มจาก Introduce Parameter Object เพื่อลด noise ก่อน จากนั้นพิจารณาว่า method กลางทางจำเป็นต้องอยู่ในเส้นทางนี้จริงหรือไม่ — ถ้าไม่ ให้ใช้ Move Method หรือ Hide Delegate เพื่อให้ผู้เรียกเข้าถึงปลายทางได้โดยไม่ต้องผ่านตัวกลางที่ไม่เกี่ยวข้อง หรือถ้า code เรียกออบเจ็กต์ทีละ field ก่อนส่งต่อ ให้พิจารณา Preserve Whole Object เพื่อส่งทั้งออบเจ็กต์แทนที่จะแยกส่งทีละค่า
ที่เกี่ยวข้อง
หัวข้อที่มีชื่อว่า “ที่เกี่ยวข้อง”- Long Parameter List — อาการที่มักเกิดคู่กับ Tramp Data เมื่อ parameter พเนจรสะสมมากขึ้นเรื่อย ๆ
- Message Chains — กลิ่นในตระกูลเดียวกัน มองจากมุมของการไล่เรียกเชนแทนการแบก parameter
- Law of Demeter Violations — หลักการที่ Tramp Data มักละเมิด
- Law of Demeter — ตัวกฎที่อธิบายว่าทำไมข้อมูลควรอยู่ใกล้จุดที่ใช้
- Single Responsibility — method กลางทางที่แบกข้อมูลไม่เกี่ยวข้องมักทำหน้าที่เกินกว่าความรับผิดชอบเดียวของมัน
- Data Clumps — เมื่อกลุ่มข้อมูลเดียวกันปรากฏซ้ำ ๆ ในหลาย signature ก็เป็นสัญญาณว่าควรรวมเป็น class เดียว เช่นเดียวกับที่ใช้แก้ Tramp Data