Python Type Annotation: ทำไม Python ต้องเขียน Type ด้วย
ตั้งแต่ Python 3.5 เป็นต้นมา Python ได้แปลงร่างจากภาษา Duck Typing เต็มตัว ให้มีความสามารถในการใส่ Type Annotation ในโค้ดซึ่งถูกพัฒนามาตั้งแต่ปี 2014 แต่ผมค้นพบว่าหลายๆ บริษัทการเขียน Type Annotation ในภาษา Python ยังไม่แพร่หลายอย่างที่มันควร ในบล็อคนี่เลยจะพาไปรู้จักตั้งแต่เหตุผลที่ว่าทำไมเราถึงควรใส่ Type Annotation จะใช้ Type Annotation ยังไง รวมถึงกลยุทธในการเพิ่ม Type Annotation เข้าไปใน Codebase และ workflow ของเราครับ
ทำไมถึงต้องมี Type Annotation
เป็นคำถามแรกเลยและคำถามสำคัญด้วย ทำไมเราถึงควรจะใส่ Type Annotation ให้กับภาษาที่เป็น Duck typing อย่าง Python ทำไมเราถึงอยากจะลดอิสระภาพในการประกาศอะไรก็ได้ ลองดูโค้ดตัวอย่างข้างล่างนี้ครับ
ดูแว็บแรกก็เหมือนจะเข้าใจง่ายใช่มั้ยครับ เป็นฟังก์ชั่นไว้ยิง request รับ url, data, headers พอผ่านไปซักหกเดือนมันเกิดคำถาม data มันรับข้อมูลแบบไหน ? มันอาจจะเป็น text ก็ได้ อาจจะเป็น dict ก็ได้ เราไม่มีทางรู้ได้เลยแต่แรกจากโค้ดว่าควรจะส่ง data หน้าตาเป็นยังไง ทีนี้เราลองมาดูฟังก์ชั่นเดียวกันที่ใส่ Type Annotation แล้วนะครับ (อย่าเพิ่งสนใจ Syntax มากนะครับ พยายามดูแบบไม่ต้องคิดอะไรมากก่อน)
จะเห็นได้เลยว่า เนี่ย data เจ้าปัญหาเราเนี่ยมันต้องส่ง Dict มานะที่ key, value เป็น str เช่นเดียวกับ headers ส่วน return ของ ฟังก์ชั่น make_request นี่ก็คืน Tuple ของ int และ str ไปตามลำดับครับ
อันนี้แค่ตัวอย่างง่ายๆ นะครับรับ arguments แค่ 3 ตัวและ return Tuple แค่ 2 ตัว ลองคิดภาพฟังก์ชั่นที่ทำงานซับซ้อนกว่านี้รับ arguments มากกว่านี้ เพียงแค่เราเขียน Type Annotation เพิ่มอีกนิดหน่อย สิ่งที่เราจะได้กลับคืนมาคือ
- Maintain code ง่ายขึ้นมาก เพราะเรา Explicit ไปเลยว่า arguments ตัวนี้รับอะไร และฟังก์ชั่นนี้คืนอะไร ทำให้คนอ่านเข้าใจโค้ดได้ดีขึ้น
- Code Review ง่ายขึ้นมากครับ เพราะคน Review ไม่จำเป็นต้องเดา Type ของฟังก์ชั่นที่กำลังดูอยู่
- Debugging ง่ายขึ้น เหตุผลเดียวกันครับ ไม่จำเป็นต้องเดา Type ของฟังก์ชั่นที่เรากำลังทำอยู่
- Validate assumption ระหว่างเราเขียนไปด้วยในตัว เคยมั้ยครับ เขียนฟังก์ชั่นส่งข้อมูลกันไปมาหลายๆ ที่แล้วเริ่มมึนและลึกจนไม่รู้ว่า มันส่งอะไรกัน การใส่ Type Annotation + Type Checker จะช่วย Proof ในส่วนนี้ให้ครับ
- ลด Cognitive Load ของ Developer ทำให้มีพื้นที่สมองส่วนที่เคยต้องใช้จำ Type เอาไปคิดส่วนอื่นมากขึ้นครับ
Type Annotation หน้าตาเป็นยังไง
โครงสร้างพื้นฐานของฟังก์ชั่นจะแตกต่างจากที่เราเคยเขียนโดยเพิ่ม : T ซึ่ง T ก็คือ Type ของ arguments ครับอีกส่วนหนึ่งคือ -> R ซึ่งก็คือ Type ของ return ของฟังก์ชั่น ตัว Type ที่เราพูดถึงกันนี่ สามารถใช้ได้ตั้งแต่ built-in Type เช่น str, int, bool, float, … ไปจนถึง collection type เช่น List, Dict, Tuple เป็นต้น แล้วจะเห็นได้ว่า Python 2 ซึ่งไม่รองรับ Syntax รูปแบบใหม่ก็จะใช้อีกแบบนึง แต่โดยรวมแล้วก็จะคล้ายๆ กัน ครับ
ตัวอย่างการเขียน Type Annotation ในแบบต่างๆ
ตัวอย่างข้างบนเป็นการเขียน Type แบบง่ายๆ ครับ แต่การเขียน Type จริงๆ ยังมีรูปแบบการเขียนอีกหลายแบบมา เดี๋ยวเราจะมาลองดูกันว่าจะเขียน Type ท่าไหนได้บ้าง
สำหรับคนขี้เกียจ
ท่านี้ผมแนะนำไว้ จะได้ไม่ทำกันครับ เพราะมันมีค่าเท่ากับไม่ต้องเขียนเลย การใช้ Type Any ไม่ได้ช่วยให้โค้ดอ่านง่ายขึ้นและ Type Checker ไม่สามารถใช้ประโยชน์จากมันได้เลยครับ
สำหรับคืนของเป็น Collection
ท่านี้ตอนเขียน Type Annotation จะเข้าใจผิดง่ายมาก ถ้าไม่อ่าน Document ของ Mypy มาก่อน คือ List, Tuple, Dict หรือ Collection ต่างๆ ถ้าเราเขียนโดยใช้ dict, list, tuple ตัวแรกตรงๆ มันจะฟ้อง run-time error ครับ List, Tuple,… ที่ module typing เตรียมไว้ให้เป็น alias class ของ type พวกนั้นครับ มีโน้ตนิดหน่อยสำหรับ Tuple ใน Python 3.6 (PEP526) ได้เพิ่ม Syntax เกี่ยวกับ Type Annotation เพิ่มขึ้นมาคือ … หมายความว่า Tuple นี้จะเป็น type str ทั้งหมด
สำหรับตัวแปรที่อาจเป็น None
ในบางครั้ง arguments ที่เรารับมาอาจจะมีค่าเป็น None ได้ เราจะบอกว่า Type นี้อาจจะเป็น type บางอย่างได้และเป็น None ได้โดยใช้ type ที่ชื่อว่า Optional ครับ ท่านี้สามารถเขียนอีกรูปแบบนึงได้คือ s: str = None ครับ
สำหรับตัวแปรที่อาจเป็นได้มากกว่า 1 type
ในกรณีนี้ arguments s สามารถเป็นได้ทั้ง int หรือ str แทนที่เราจะเลือกให้มันเป็น type ใด type หนึ่ง เราสามารถให้มันเป็น Union Type ครับคือเป็นได้ทั้ง str หรือ int ท่านี้จะได้ใช้บ่อยมากๆ เวลาเราเขียน Type ของ dictionary แล้วเราจะอธิบาย value ของ dict อย่างละเอียดครับ
สำหรับ arguments กับ return เป็น type เดียวกัน
ตอนที่ผมเห็นท่านี้ครั้งแรกมันเกิดคำถามขึ้นเลยครับ ว่าจะได้ใช้เมื่อไร แต่คำถามนั้นมันเกิดก่อนผมจะเริ่มมาเขียน type annotation จริงจัง ผมค้นพบว่า บางครั้งเราเขียน type ที่ซับซ้อนมากๆ พวกที่มี Union, Optional ปนอยู่ การจะต้องมาเขียนหลายๆ รอบซ้ำๆ มัน duplicate เยอะและยาว ก็เลย define เป็น TypeVar จะง่ายกว่ามากครับ
สำหรับ inherit type จาก type อื่น
บางครั้งการใช้ built-in type อาจจะไม่สื่อความหมายเพียงพอเช่น int อาจจะเป็นเลขอะไรก็ได้ ขอแค่เป็นเลข การสร้าง type ใหม่จาก type เดิมคือคำตอบครับ ผมว่าเคสตัวอย่างนี้อธิบายได้ดีมากว่าทำไมบางครั้งเราควรสร้าง Type ใหม่ขึ้นมา
สำหรับรับ class ไม่ใช่ object ที่ instantiate แล้วจาก class
บางครั้งเราก็มีเคสที่ต้องการ type ของ class จริงๆ ไม่ใช่ Object ที่ถูกสร้างจาก Class เราต้องห่อมันด้วย Type[] ครับ ดูมีประโยชน์ในชีวิตจริงผมก็ยังไม่เคยใช้เหมือนกัน
จริงๆ ยังมีตัวอย่างอื่นอีกเยอะมากครับแนะนำให้ไปดู Mypy syntax cheat sheet (Python 3) จะมีทุกท่าที่ผมยกตัวอย่างมา รวมถึงท่าอื่นๆ ที่ผมไม่ได้พูดถึงเช่น variable annotation ด้วยครับ
Python เดา Type จาก Type Annotation ได้ด้วยนะเออ
หัวข้องงๆ ลองดูตัวอย่างดูละกันครับ จากตัวอย่างแรกจะเห็นว่าฟังก์ชั่น f รับ arguments l ที่เป็น list ของ str แล้วข้างในฟังก์ชั่นมีตัวแปร s ที่เรียกของข้างใน list l ตัว Mypy เวลาอ่านจะทำ type inference ว่า s เนี่ยต้องเป็น str ให้เราเลย โดยที่เราไม่ต้องเขียน type ตรงตัวแปร s ก็ได้
ตัวอย่างที่สองจะเป็นการลด scope ของการ inference จะเห็นได้ว่าในฟังก์ชั่น g รับ arguments message โดยที่ message เนี่ยอาจเป็นได้ทั้ง str หรือ None ครับ ถ้าเราเช็ค type ผ่านคำสั่ง reveal_type ตรงก่อนบรรทัดที่ห้าจะได้ตามนั้น แต่เราสามารถลดความเป็นไปได้ของ type ผ่าน condition ได้ จะเห็นว่าพอเราใช้คำสั่ง reveal_type ในบรรทัดที่ 7 ความเป็นไปได้ของ message จะเหลือแค่ str ครับ
ลืมแนะนำ reveal_type ครับเป็น built-in ที่มาพร้อมกับ Mypy เอาไว้ check type ของตัวแปรได้ โดย Mypy มันจะขึ้น message บอกหน้าตาประมาณนี้ครับ
สร้าง Type ใหม่ผ่านการ Cast
สมมติว่าเราลองทุกอย่างละทุกท่า built-in, Union, Optional (แต่ไม่ Any) นะแล้วยัง Failed อยู่ เราสามารถสร้าง type ใหม่ได้ครับกระบวนการนี้เรียกว่า Cast ลองดูตัวอย่างข้างล่างได้ครับครับ
ทำความรู้จักกับ Type Checker
หลังจากที่เราเพิ่ม Type Annotation ให้กับ Codebase ของเราไปแล้ว เราก็ควรจะใช้ประโยชน์จากมันให้เต็มที่ โดยในภาษา static type อย่างเช่นภาษา C นี่คนที่ใช้ประโยชน์ตรงนี้คือ Compiler ครับที่จะ Proof ว่า ข้อมูลที่เราส่งไปมาระหว่างฟังก์ชั่นเนี่ย มันถูก Type อย่างที่มันควรจะเป็นรึยัง สำหรับ Python นั้นเราใช้ Mypy ครับ
Mypy เป็น static type checker ที่ถูกพัฒนาโดย Guido van Rossum มาตั้งแต่ปี 2012 ครับ ก่อนมาตรฐาน Type Annotation PEP484 ออกซะอีก โดยวิธีใช้ก็เพียงแค่สั่ง Mypy แล้วตามด้วยไฟล์ที่เราต้องการจะเช็ค Mypy ก็จะทำการอ่าน Type Annotation ให้และดูว่าส่วนประกอบต่างๆ ที่เรียกใช้มันในไฟล์นั้น เรียกใช้ตาม Type อย่างที่มันควรจะเป็นรึเปล่า ซึ่งจริงๆ แล้ว Mypy มี option เยอะกว่านั้นมากครับ แต่ผมไม่เคยใช้ฮาาา
ส่วนตัวผมเองนั้นจะไม่ใช้ Mypy ตรงๆ โดยจะใช้ผ่าน package ชื่อว่า flake8-mypy ครับ เพราะว่าโดยปกติแล้วใน Codebase ของเรามักจะลง flake8 ไว้อยู่แล้ว พอเราลง flake8-mypy ไปทำให้เราเพิ่มความสามารถของ flake8 ให้เช็ค Type Annotation ของไฟล์นั้นๆ ไปเลยด้วย แล้วโดยปกติแล้วใน Vim ผมเองจะเซต ale ให้รัน Flake8 แบบ Asynchronous อยู่แล้ว ทำให้แทบจะเช็ค Type ได้แบบเกือบจะในทันทีเลย
สำหรับผู้ใช้ PyCharm นั้นไม่ต้องลง Plugin อะไรเลยครับ เพราะตัว PyCharm เองได้พัฒนาส่วน Type Checker ขึ้นมาเองโดยที่ไม่ขึ้นกับ Mypy เลยครับ ส่วน Text Editor ตัวอื่นก็ใช้ได้นะครับอย่าง Atom / VSCode ถ้าเซ็ตให้มันเช็ค lint จาก flake8 อยู่แล้วผมคิดว่าน่าจะไม่ต้อง config อะไรเพิ่มเติมเลย
กลยุทธในการเพิ่ม Type Annotation
เพิ่มมันเลยจะรออะไรหละ
ใส่เข้าไปเลยครับ ไหนๆ เราก็รู้วิธีเขียนแล้ว แล้วก็รู้แล้วว่ามันดีขนาดนี้จะรออะไรอยู่หละครับ ใส่เข้าไปเยอะๆ แต่เดี๋ยวก่อนวิธีนี้มีข้อเสียสูงมาก เพราะเราไม่ได้ใช้ประโยชน์อะไรจาก Type Checker เลย นั่นหมายความว่า ถ้าเราเพิ่มผิดขึ้นมาเวลาแก้ทีมันจะผิดเยอะมาก แล้วเราต้องไปไล่เก็บทีละไฟล์ๆ เหนื่อยครับบอกเลย
Gradual Typing
เทคนิคนี้เป็นเทคนิคที่ Instagram ใช้ครับ วิธีการก็คือเราหาฟังก์ชั่นพื้นฐานสุดที่ไม่เรียกใช้คนอื่นแล้ว เราเพิ่มจากตรงนั้นเป็นจุดแรก อันนี้สำคัญมาก เพราะถ้าเราเลือกฟังก์ชั่นที่ไม่พื้นฐานจริงๆ เราต้องแก้กลับไปกลับมาหลายรอบจนกว่าจะถูก จากนั้นเราทำการรัน Type Checker ครับตัว Type Checker ก็จะด่าเราถ้าฟังก์ชั่นที่เรียกฟังก์ชั่น basic สุดเรามันส่งค่ามาผิด type ครับ กฏข้อสำคัญคือ
แก้เฉพาะฟังก์ชั่นที่ Type Checker ด่าเท่านั้น ฟังก์ชั่นอื่นๆ ถือว่ารับและ return Any
วิธีการนี้จะทำให้ Codebase เราค่อยๆ เปลี่ยนจากไม่มี Type เป็นมี Type อย่างมั่นคงครับ ต้องใช้เวลาค่อยเป็นค่อยไป แต่พอถึงจุดนึง Codebase เราจะเต็มไปด้วย type และลดปัญหาเรื่อง TypeError ไปได้มากโขเลยครับ
นอกจากค่อยๆ แก้แล้วสิ่งที่แนะนำอีกอย่างนึงคือ ควรจะใช้ mypy เช็ค Codebase โดยรวมด้วยผ่าน Continuous Integration workflow เราด้วยจะได้เป็นการป้องกันอีกชั้นว่าสิ่งที่เราพยายามเพิ่มกันมาไม่สูญหายไป
Automate generate type using MonkeyType
อันนี้เรียกว่าเสริมเทคนิคที่แล้วละกัน การที่เราต้องมานั่งพยายามเขียน Type บางครั้งมันก็เหนื่อยมาก คนที่ Instagram (อีกแล้ว) เลยสร้าง tool ตัวนี้ขึ้นมา Monkey type เนี่ยจะไปอ่าน type จาก run-time ของ code ซึ่ง run-time อาจจะเป็น run-time จริงๆ หรือ Unit-test ก็ได้ เก็บไว้ในไฟล์จากนั้นก็ generate type ขึ้นมาจาก run-time ที่อ่านได้ แล้วเราสามารถ patch ฟังก์ชั่นได้เลย วิธีนี้เพิ่มได้เร็วมาก แต่ข้อเสียก็มากโดยเฉพาะถ้าใช้กับ Unit-test เราจะเจอ Mock type อะไรแบบนี้ก็ต้องมานั่งแก้ แต่ก็ช่วยลดเวลาไปได้ระดับนึงเลยทีเดียว ถ้าสนใจเพิ่มเติม อ่านได้ที่นี่เลยครับ
แล้วไงต่อ
Type Annotation ยังค่อนข้างเป็นเรื่องใหม่ใน Python มากๆ ครับ เพราะฉะนั้น tooling หลายๆ อย่างเลยยังค่อนข้างน้อยอยู่ แต่ผมแนะนำสองสามที่ๆ ไปต่อได้ครับ
- ถ้าอยากจะรู้เพิ่มเติมเกี่ยวกับการเขียน Type Annotation ที่ๆ ดีที่สุดที่ควรอ่านคือ official document ของ Mypy เลยครับที่นี่ http://mypy.readthedocs.io/en/latest/index.html ถ้ารีบตรงไปอ่าน Cheatsheet เลยครับ มีตัวอย่างครบแทบทุกแบบ
- อาจจะเป็นความบ้าส่วนตัวผมก็ได้นะ แต่ถ้าอยากศึกษาที่มาที่ไป ทำไมสุดท้ายถึงมาลงตัวรูปแบบ Annotation แบบนี้แนะนำให้ไปอ่าน PEP ที่เกี่ยวกับ Type Annotation เลยครับตามนี้เลย PEP484 PEP526 PEP544 PEP563
- โพสนี้ได้แรงบันดาลใจมาหลังจากผมดู Talk นี้ครับจาก PyCascade แนะนำให้ไปดูกันเค้าเล่าสนุกมาก รวมถึง issues แปลกๆ ที่เค้าเจอตอนพยายามเพิ่ม type เข้าไปใน Codebase ของ instagram ครับ
Original post at: https://yothinix.medium.com/python-type-annotation-ทำไม-python-ต้องเขียน-type-ด้วย-2559187f94ed