Backward Compatibility (การเข้ากันได้ย้อนหลัง) ของ API

Backward Compatibility (การเข้ากันได้ย้อนหลัง) ของ API
.NET Core และ C# language ถูกปรับ version ขึ้นอยู่เสมอเพื่อให้รองรับความสามารถใหม่ ๆ และแก้ไขข้อบกพร่องที่ได้รับรายงาน
สิ่งที่น่าสังเกตคือโค้ดที่เราเขียนเพื่อให้ทำงานใน .NET Core 2.0 ควรจะทำงานได้อย่างไม่สะดุดใน .NET Core 3.0
นั่นคือ Framework มีคุณสมบัติ Backward Compatibility (ความเข้ากันได้ย้อนหลัง) กับ version ก่อนหน้า ซึ่งเป็นสิ่งที่ผู้พัฒนาโค้ดควรสนใจศึกษา
หากท่านกำลังพัฒนา API (Application Programming Interface) ท่านย่อมต้องการให้ library หรือ framework ของท่านมี Backward Compatibility เช่นกัน
เพราะถ้าไม่เป็นอย่างนั้นโค้ดส่วน client จะทำงานกับ API version ใหม่ไม่ได้ซึ่งเป็นสิ่งที่เราต้องการหลีกเลี่ยง
Backward Compatibility มีสองระดับคือ
สิ่งที่น่าสังเกตคือโค้ดที่เราเขียนเพื่อให้ทำงานใน .NET Core 2.0 ควรจะทำงานได้อย่างไม่สะดุดใน .NET Core 3.0
นั่นคือ Framework มีคุณสมบัติ Backward Compatibility (ความเข้ากันได้ย้อนหลัง) กับ version ก่อนหน้า ซึ่งเป็นสิ่งที่ผู้พัฒนาโค้ดควรสนใจศึกษา
หากท่านกำลังพัฒนา API (Application Programming Interface) ท่านย่อมต้องการให้ library หรือ framework ของท่านมี Backward Compatibility เช่นกัน
เพราะถ้าไม่เป็นอย่างนั้นโค้ดส่วน client จะทำงานกับ API version ใหม่ไม่ได้ซึ่งเป็นสิ่งที่เราต้องการหลีกเลี่ยง
Backward Compatibility มีสองระดับคือ
- Backward Compatibility ในระดับ source code
- Backward Compatibility ในระดับ binary

เพื่อให้เข้าใจเรื่อง Backward Compatibility ชัดเจนขึ้น ลองมาพิจารณาโค้ดที่อาจทำให้เกิดปัญหาต่อ Backward Compatibility สักเล็กน้อย
สมมุติว่าเรามี library ที่มีโค้ดอย่างที่เห็นในรูปที่ 1 บรรทัดที่ 8 นี่คือ version แรก เรามี method Foo() แบบไม่มี parameter อยู่
ต่อมาใน version ที่ 2 เรา overload method นี้ ด้วยการใส่ method Foo(int x) ซึ่งมี parameter 1ตัว
การสร้าง Action โดยเรียกกลุ่ม method HandleAction ด้วย library version1 ทำได้โดยไม่มีปัญหา
แต่ปัญหาจะเกิดเมื่อทำงานกับ library version 2 เพราะจะเกิดความกำกวมเนื่องจากการเรียกกลุ่ม method HandleAction อาจถูกแปลงไปเป็น Action หรือ Action<int> ก็ได้

Reference Types ที่ไม่เกี่ยวข้อง
ในรูปที่หนึ่งปัญหาเกิดเพราะเรามี method Foo() แบบไม่มี parameter แล้วเรา overload method นี้ ด้วยการใส่ method Foo(int x) ซึ่งมี parameter 1 ตัว ใน version ใหม่
คราวนี้มาดูรูปที่ 2 ซึ่งเป็น overload เหมือนกัน และ method ใหม่ก็มี parameter เพียงตัวเดียวเหมือนกัน โค้ดแบบนี้จะนำไปสู่ปัญหาอีกลักษณะหนึ่ง
โค้ดบรรทัดที่ 6-10 คือ library version แรก มี method Foo ที่มี parameter แบบ string 1 ตัว
โค้ดบรรทัดที่ 11-15 คือ library version ที่สอง มี method Foo ที่มี parameter แบบ string 1 ตัวเหมือนเดิม
และ overload method ด้วยการนิยาม method Foo อีกหนึ่งตัว มี parameter 1 ตัวเหมือนเดิม แต่ type เป็น FileStream
การทำเช่นนี้ดูแล้วก็ไม่น่าจะมีปัญหาอะไร เพราะ method เดิมก็ยังอยู่ binary จึงน่าจะ run ได้ตามปกติ
แต่การเข้ากันได้ในระดับ source code จะมีปัญหาหากทำงานได้ใน version 1.0 แต่ไม่ทำงานใน 1.1 หรือในทั้งสอง version หรือทำงานได้แต่มีการทำงานที่แตกต่างกันระหว่าง version 1.1 กับ 1.0
สาเหตุเกิดจากเราจะต้องมี argument ที่เข้ากันได้ระหว่าง string และ FileStream แต่ทั้ง 2 type นี้เป็น reference types ที่ไม่เกี่ยวข้องกัน
ปัญหาอย่างแรกคือถ้าเรามี “โค้ดแปลงค่าโดยนัย” ที่เรากำหนดขึ้นเองเพื่อให้ทำงานกับทั้ง string และ FileStream
ยกตัวอย่างเช่นเรา overload ตัวกระทำใน Class OddlyConvertible โดยบรรทัดที่ 20, 21 เรา overload ตัวกระทำต่อ type string
และบรรทัดที่ 22, 23 เรา overload ตัวกระทำต่อ type FileStream ต่อมาดู method Method1()
ในบรรทัดที่ 31 เราเรียกใช้ method Foo โดยมี argument ที่อ้างถึง convertible ซึ่งเป็น object ที่เกิดจาก Class OddlyConvertible จะเกิดความกำกวมว่าเรากำอ้างถึง Foo(string x) หรือ Foo(FileStream x) กันแน่
ถ้าเราต้องการหลีกเลี่ยงความกำกวมโดยเขียนใหม่อย่าง Method2() คือ เราตัด object ที่เกิดจาก Class OddlyConvertible ออกไป แล้วเราเรียกใช้ method Foo โดยมี argument เป็น null อย่างนี้ก็ยังเป็นปัญหาเหมือนเดิม เพราะจะเกิดความกำกวมเมื่อใช้กับ library version 1.1 เพราะไม่มีอะไรบอกได้ว่าเรากำอ้างถึง Foo(string x) หรือ Foo(FileStream x) กันแน่
คราวนี้มาดูรูปที่ 2 ซึ่งเป็น overload เหมือนกัน และ method ใหม่ก็มี parameter เพียงตัวเดียวเหมือนกัน โค้ดแบบนี้จะนำไปสู่ปัญหาอีกลักษณะหนึ่ง
โค้ดบรรทัดที่ 6-10 คือ library version แรก มี method Foo ที่มี parameter แบบ string 1 ตัว
โค้ดบรรทัดที่ 11-15 คือ library version ที่สอง มี method Foo ที่มี parameter แบบ string 1 ตัวเหมือนเดิม
และ overload method ด้วยการนิยาม method Foo อีกหนึ่งตัว มี parameter 1 ตัวเหมือนเดิม แต่ type เป็น FileStream
การทำเช่นนี้ดูแล้วก็ไม่น่าจะมีปัญหาอะไร เพราะ method เดิมก็ยังอยู่ binary จึงน่าจะ run ได้ตามปกติ
แต่การเข้ากันได้ในระดับ source code จะมีปัญหาหากทำงานได้ใน version 1.0 แต่ไม่ทำงานใน 1.1 หรือในทั้งสอง version หรือทำงานได้แต่มีการทำงานที่แตกต่างกันระหว่าง version 1.1 กับ 1.0
สาเหตุเกิดจากเราจะต้องมี argument ที่เข้ากันได้ระหว่าง string และ FileStream แต่ทั้ง 2 type นี้เป็น reference types ที่ไม่เกี่ยวข้องกัน
ปัญหาอย่างแรกคือถ้าเรามี “โค้ดแปลงค่าโดยนัย” ที่เรากำหนดขึ้นเองเพื่อให้ทำงานกับทั้ง string และ FileStream
ยกตัวอย่างเช่นเรา overload ตัวกระทำใน Class OddlyConvertible โดยบรรทัดที่ 20, 21 เรา overload ตัวกระทำต่อ type string
และบรรทัดที่ 22, 23 เรา overload ตัวกระทำต่อ type FileStream ต่อมาดู method Method1()
ในบรรทัดที่ 31 เราเรียกใช้ method Foo โดยมี argument ที่อ้างถึง convertible ซึ่งเป็น object ที่เกิดจาก Class OddlyConvertible จะเกิดความกำกวมว่าเรากำอ้างถึง Foo(string x) หรือ Foo(FileStream x) กันแน่
ถ้าเราต้องการหลีกเลี่ยงความกำกวมโดยเขียนใหม่อย่าง Method2() คือ เราตัด object ที่เกิดจาก Class OddlyConvertible ออกไป แล้วเราเรียกใช้ method Foo โดยมี argument เป็น null อย่างนี้ก็ยังเป็นปัญหาเหมือนเดิม เพราะจะเกิดความกำกวมเมื่อใช้กับ library version 1.1 เพราะไม่มีอะไรบอกได้ว่าเรากำอ้างถึง Foo(string x) หรือ Foo(FileStream x) กันแน่

Parameter แบบ Parameter แบบ Reference Types กับแบบที่ค่าเป็น Null ไม่ได้
ในตัวอย่างที่ผ่านมาปัญหาความกำกวมเกิดจากทั้ง string และ FileStream เป็น parameter แบบ Reference Types ซึ่งมีค่าเป็น null ได้
แล้วถ้าเรา overload method ใน library เป็น Data Type แบบ int ซึ่งมีค่าเป็น null ไม่ได้ (ดูบรรทัดที่ 12 ในรูป 3) อย่างนี้ไม่น่าจะมีปัญหา
เพราะถ้าเราเขียนว่า library2.Foo(null) มันย่อมตรงกับ signature ของ method Foo(string x) แต่ไม่ตรงกับ signature ของ method Foo(int x) ดังนั้นความกำกวมจึงไม่น่าจะเกิด
การทำแบบนี้จึงดูเหมือนว่าจะปลอดภัย แต่ปัญหาจะเกิดใน C# 7.1 ลองคิดดูว่าหากโค้ดส่วน client เราใช้คำสั่ง default อย่างที่เห็นในบรรทัด 19 เพราะคำสั่งนี้มีการทำงานคล้าย null แต่จะทำงานได้กับทุก type จึงมีผลให้ตรงกับ method signature ของ method Foo ทั้งสองแบบ จึงทำให้เกิดปัญหาความกำกวมซึ่งมีผลให้ source code เก่า Compile ไม่ผ่านทันที
แล้วถ้าเรา overload method ใน library เป็น Data Type แบบ int ซึ่งมีค่าเป็น null ไม่ได้ (ดูบรรทัดที่ 12 ในรูป 3) อย่างนี้ไม่น่าจะมีปัญหา
เพราะถ้าเราเขียนว่า library2.Foo(null) มันย่อมตรงกับ signature ของ method Foo(string x) แต่ไม่ตรงกับ signature ของ method Foo(int x) ดังนั้นความกำกวมจึงไม่น่าจะเกิด
การทำแบบนี้จึงดูเหมือนว่าจะปลอดภัย แต่ปัญหาจะเกิดใน C# 7.1 ลองคิดดูว่าหากโค้ดส่วน client เราใช้คำสั่ง default อย่างที่เห็นในบรรทัด 19 เพราะคำสั่งนี้มีการทำงานคล้าย null แต่จะทำงานได้กับทุก type จึงมีผลให้ตรงกับ method signature ของ method Foo ทั้งสองแบบ จึงทำให้เกิดปัญหาความกำกวมซึ่งมีผลให้ source code เก่า Compile ไม่ผ่านทันที

พารามิเตอร์เผื่อเลือก (Optional parameters)
parameter แบบทางเลือก คือ ตอนที่เรียกใช้ method เราจะส่งผ่าน argument มาให้มันหรือไม่ส่งก็ได้
หากเราเรียกใช้ method นี้โดยส่งargument ไปให้ ก็จะนำค่านั้นไปใช้ แต่ถ้าไม่ส่งก็ไม่ Error จะนำค่า “ ” (ค่าว่าง) ซึ่งเป็นค่าโดยปริยายที่กำหนดไว้ใน signature ไปใช้
การใช้ parameter แบบทางเลือกเมื่อบวกกับการทำ method overload ใน library version ใหม่อาจนำมาซึ่งปัญหาแบบใหม่ที่มีผลกระทบต่อ Backward Compatibility ในระดับ source code และ binary ลองพิจารณาโค้ดต่อไปนี้
- ถ้าส่งมาให้มันจะนำค่านั้นไปใช้
- ถ้าไม่ส่งมันจะมีค่าโดยปริยายที่กำหนดให้ก่อนแล้ว
หากเราเรียกใช้ method นี้โดยส่งargument ไปให้ ก็จะนำค่านั้นไปใช้ แต่ถ้าไม่ส่งก็ไม่ Error จะนำค่า “ ” (ค่าว่าง) ซึ่งเป็นค่าโดยปริยายที่กำหนดไว้ใน signature ไปใช้
การใช้ parameter แบบทางเลือกเมื่อบวกกับการทำ method overload ใน library version ใหม่อาจนำมาซึ่งปัญหาแบบใหม่ที่มีผลกระทบต่อ Backward Compatibility ในระดับ source code และ binary ลองพิจารณาโค้ดต่อไปนี้
- บรรทัดที่ 3-6 คือlibrary version 1.0 ที่มี method Foo เพียงตัวเดียวโดยเป็น method ที่มี parameter แบบทางเลือกหนึ่งตัว
- บรรทัดที่ 7-11 คือ library version 1.1a ที่มี method Foo สองตัว ตัวแรก (บรรทัดที่ 9) เป็น method ที่มี parameter แบบทางเลือก 1ตัว ตัวที่2 (บรรทัดที่ 10) เป็น method ที่มี parameter แบบทางเลือก2ตัว
- บรรทัดที่ 12-15 คือ library version 1.1b ที่มี method Foo ตัวเดียว (บรรทัดที่ 14) เป็น method ที่มี parameter แบบทางเลือก 2ตัว
- บรรทัดที่ 16-20 คือlibrary version 1.1c ที่มีmethod Foo สองตัว ตัวแรก (บรรทัดที่ 18) เป็นmethodที่มีparameterแบบธรรมดา 1ตัว ตัวที่2 (บรรทัดที่ 10) เป็น method ที่มี parameter แบบทางเลือก 2ตัว
- library version 1.0a: ไม่มีปัญหาเรื่อง Backward Compatibility ทาง binary แต่มีปัญหาเรื่อง Backward Compatibility ทาง source code เพราะจะเกิดความกำกวม เพราะกา รoverload ใน c# language ไม่มีข้อกำหนดว่า Compile จะต้องใส่ค่าให้แก่ parameter แบบทางเลือกทั้งหมดกี่ตัว
- library version 1.1b: มีปัญหาเรื่องความเข้ากันได้ย้อนหลังทาง binary แต่ไม่มีปัญหาเรื่องความเข้ากันได้ย้อนหลังทาง source code คือ Compile ได้ แต่อาจทำงานผิดจากเจตนา เพราะหากเราเรียกหา Foo โดยใส่ argument เพียงตัวเดียว เพราะต้องการเรียก Foo แบบที่มี parameter 1ตัว โค้ดจะ Compile ผ่านทั้ง ๆ ที่ในlibrary ไม่มี Foo แบบที่ว่านั้นอยู่เลย
- library version 1.1c: ไม่มีปัญหาเรื่อง Backward Compatibility ทาง binary แต่จะมีปัญหาเรื่องความ Backward Compatibility ทาง source code ในกรณีนี้ library.Foo("xyz") ในบรรทัดที่ 27 อ้างถึง method Foo ในบรรทัดที่ 18 ได้อย่างที่ควรจะเป็น แต่ปัญหาคือเราจะเรียกใช้ Foo ตัวบรรทัดที่ 19 โดยใส่ argument เพียงตัวเดียวไม่ได้ จำเป็นต้องใส่ให้ครบทั้ง ๆ ที่มันเป็นแบบทางเลือก

Generic
ปัญหาเรื่องความเข้ากันได้ย้อนหลังในระดับ source code และ binary จะยิ่งซับซ้อนขึ้นอีกเมื่อมีการทำ method overload กับ Type ที่เป็น generic
รูปที่ 5 บรรทัดที่ 3-6 เรามี library version 1 ที่มี method ธรรมดา 1 ตัวซึ่งมี parameter เป็น object
บรรทัด 7-11 เรามี library version 1.1 ที่มี method ธรรมดา 1 ตัวซึ่งมี parameter เป็น object บวกการ overload เป็นแบบ generic (บรรทัด 10)
ดูเผินๆ อาจไม่มีอะไรที่เป็นปัญหา แต่ถ้าพิจารณาโค้ดที่นำไปใช้งาน (บรรทัด 16-18) ให้ดี ๆ จะพบว่าถ้าเรานำ cilent ที่ Compile กับ library version 1 ไป run ใน library version 1.1 จะสามารถ run ได้ โค้ดบรรทัด 17 และ 18 ต่างอ้างถึง method Foo บรรทัดที่ 9 ได้อย่างถูกต้องทั้งคู่
แต่ถ้าเรานำ source code ของ cilent มา Compile กับ library version 1.1 เพื่อ run ในlibrary version 1.1 จะเกิดผลลัพธ์ที่แตกต่างออกไป
คำสั่งบรรทัดที่ 18 จะไปเรียก Foo บรรทัดที่ 10 แทนที่จะไปเรียก Foo บรรทัดที่ 9 เพราะการเรียกนั้นเข้ากันได้กับ generic
ถ้าทั้งสองการเรียก มีการทำงานคล้าย ๆ กันก็ไม่เป็นไร แต่ถ้ามีการทำงานแตกต่างกันจะเกิดปัญหา Backward Compatibility ในระดับ source code ในลักษณะที่ค้นหาและ Debug ยาก
รูปที่ 5 บรรทัดที่ 3-6 เรามี library version 1 ที่มี method ธรรมดา 1 ตัวซึ่งมี parameter เป็น object
บรรทัด 7-11 เรามี library version 1.1 ที่มี method ธรรมดา 1 ตัวซึ่งมี parameter เป็น object บวกการ overload เป็นแบบ generic (บรรทัด 10)
ดูเผินๆ อาจไม่มีอะไรที่เป็นปัญหา แต่ถ้าพิจารณาโค้ดที่นำไปใช้งาน (บรรทัด 16-18) ให้ดี ๆ จะพบว่าถ้าเรานำ cilent ที่ Compile กับ library version 1 ไป run ใน library version 1.1 จะสามารถ run ได้ โค้ดบรรทัด 17 และ 18 ต่างอ้างถึง method Foo บรรทัดที่ 9 ได้อย่างถูกต้องทั้งคู่
แต่ถ้าเรานำ source code ของ cilent มา Compile กับ library version 1.1 เพื่อ run ในlibrary version 1.1 จะเกิดผลลัพธ์ที่แตกต่างออกไป
คำสั่งบรรทัดที่ 18 จะไปเรียก Foo บรรทัดที่ 10 แทนที่จะไปเรียก Foo บรรทัดที่ 9 เพราะการเรียกนั้นเข้ากันได้กับ generic
ถ้าทั้งสองการเรียก มีการทำงานคล้าย ๆ กันก็ไม่เป็นไร แต่ถ้ามีการทำงานแตกต่างกันจะเกิดปัญหา Backward Compatibility ในระดับ source code ในลักษณะที่ค้นหาและ Debug ยาก
END CREDIT
บทความตอนนี้พูดถึง Backward Compatibility ของ API ซึ่งคนโค้ดควรรู้และระมัดระวังเมื่อใช้งาน Framework ที่ออก version ใหม่กับ source code เก่าหรือ binary ที่ Compile ไว้กับ version เก่า หรือเมื่อเขียน library ใช้งานเอง เช่น- ปัญหา Reference Types ที่ไม่เกี่ยวข้อง parameter แบบ Reference Types กับแบบที่ค่าเป็น null ไม่ได้ parameter ทางเลือก (Optional parameters) และ
- ปัญหาเรื่องความเข้ากันได้ย้อนหลังในระดับ source code และ binary เมื่อมีการทำ method overload กับ Type ที่เป็น Generic