งม Python bytecode — variable assignment
ในวันที่คุณเหนื่อยล้า โปรเจคป.โท ไม่คืบหน้า เลยเอาเวลาว่างมางม Python internal เล่น แก้เซง งืมๆ
ปกติใน Python มันจะมีการแปลง code เราเป็น bytecode ก่อน แล้วค่อยนำไป execute อีกที ลองหาอ่านได้ตาม google เลย บางคนก็เรียกขั้นตอน code -> bytecode ว่า compilation
เราสามารถแงะ bytecode มาดูได้ จะได้เข้าใจว่าด้านล่างมันทำงานยังไง บางครั้ง Pythonic code มันหลอกตา วิธีแงะออกมาดูก็ง่ายๆ ใช้ module dis ช่วย
Assign ตัวแปรตรงๆ
import dis
def test():
a = 5
dis.dis(test)
2 0 LOAD_CONST 1 (5)
2 STORE_FAST 0 (a)
4 LOAD_CONST 0 (None)
6 RETURN_VALUE
ก็ง่ายๆตรงไปตรงมา โหลด const 5 ออกมา จากนั้น store ลงตัวแปร a โดย param 0 คือตัวแหน่งของตัวแปร local (a) ของ scope นี้ จากนั้น load_const None ขึ้นมาเพื่อ return แปลว่าต่อให้เราไม่เขียน return มันก็มี return อยู่ดี
ลองใส่ b = 6 เพิ่มก็ได้ จะเห็นว่าตอน STORE_FAST b เป็น param 1 แทนละ ส่วน LOAD_CONST มันเริ่มที่ 1 เพราะว่า 0 มันโดน None จองไปละ
OP ต่างๆ ดูได้จากอันนี้ https://docs.python.org/3/library/dis.html
เลข 2 ด้านหน้าสุด มันคือ บรรทัดของ source code
ส่วน 0 2 4 6 มันคือตัวระบุตำแหน่งของ operation ใน byte stream
test.__code__.co_code
b'd\x01}\x00d\x00S\x00'
[print(hex(b), end =" ") for b in test.__code__.co_code]
0x64 0x1 0x7d 0x0 0x64 0x0 0x53 0x0
cloudpickle ใช้ bytecode พวกนี้แหละในการส่ง function ข้ามเครื่องไปมา ที่จริงใต้ __code__ มันมีเยอะกว่า bytecode เยอะ มีเก็บตัวแปร local, closure, อะไรต่างๆอีก แต่ผมจะไม่ลงตรงนี้ละกัน ถ้าสนใจ ผมคิดว่าอันนี้สรุปได้ดี https://www.codeguage.com/courses/python/functions-code-objects
Multiple assignment
def test():
a = b = 3
dis.dis(test)
2 0 LOAD_CONST 1 (3)
2 DUP_TOP
4 STORE_FAST 0 (a)
6 STORE_FAST 1 (b)
8 LOAD_CONST 0 (None)
10 RETURN_VALUE
อันนี้ก็ง่ายๆ โหลด 3 มาอยู่บน stack แล้ว dup มัน จะได้ store ลงไปได้ทั้ง a และ b
Unpacking
def test1():
a,b = 1,2
def test2():
a,b = (1,2)
dis.dis(test1)
2 0 LOAD_CONST 1 ((1, 2))
2 UNPACK_SEQUENCE 2
4 STORE_FAST 0 (a)
6 STORE_FAST 1 (b)
8 LOAD_CONST 0 (None)
10 RETURN_VALUE
dis.dis(test2)
2 0 LOAD_CONST 1 ((1, 2))
2 UNPACK_SEQUENCE 2
4 STORE_FAST 0 (a)
6 STORE_FAST 1 (b)
8 LOAD_CONST 0 (None)
10 RETURN_VALUE
ทั้งสองเคสได้ bytecode เหมือนกัน น่าสนใจตรงคำสั่ง UNPACK_SEQUENCE แล้วมี param เป็น 2 พอดีด้วย ก็คือให้ unpack ออกมาสองชุดแหละ
ต่อมาลองท่า unpack 3 ไปใส่ 2 ตัวแปรดู
def test():
a,b = 1,2,3
return a,b
dis.dis(test)
2 0 LOAD_CONST 1 ((1, 2, 3))
2 UNPACK_SEQUENCE 2
4 STORE_FAST 0 (a)
6 STORE_FAST 1 (b)
3 8 LOAD_FAST 0 (a)
10 LOAD_FAST 1 (b)
12 BUILD_TUPLE 2
14 RETURN_VALUE
test()
---------------------------------------------------------------------------
ValueError Traceback (most recent call last)
Input In [110], in <cell line: 1>()
----> 1 test()
Input In [109], in test()
1 def test():
----> 2 a,b = 1,2,3
3 return a,b
ValueError: too many values to unpack (expected 2)
ซึ่งปกติ ถ้าเจอแบบนี้ก็จะระเบิด ทุกคนที่เขียน Python น่าจะเคยโดนกันหมด เอ… เราสามารถแอบแก้ bytecode ให้เป็นเลข 3 แทนโดยที่ ตัวแปรด้านซ้าย a,b แค่สองตัวเหมือนเดิมอยู่ได้ไหมนะ
ปกติ การหยิบ function bytecode ถือเป็น immutable แปลว่าหยิบมาแก้ไม่ได้ ซึ่งก็สมเหตุสมผลแหละ function compile ออกมาแล้ว มันไม่ควรจะโดนแก้แล้ว เดี๋ยว internal ระเบิด -*- ถ้าอยากสร้าง function จาก bytecode เลยต้อง construct function ขึ้นมาใหม่ตั้งแต่แรก ผมเลยใช้ cloudpickle ช่วยแทน (ซึ่งด้านหลังมันสร้าง function จาก bytecode เป็น input ให้ แล้วก็ยัง initialize ค่า __code__ ต่างๆที่ function ต้องมีให้ด้วย เช่น array ตัวแปร, ค่าคงที่, globals, etc.)
import cloudpickle
s = cloudpickle.dumps(test)
import pickletools
pickletools.dis(s)
99: K BININT1 2
101: K BININT1 67
103: C SHORT_BINBYTES b'd\x01\\\x02}\x00}\x01|\x00|\x01f\x02S\x00'
121: \x94 MEMOIZE (as 9)
122: N NONE
123: K BININT1 1
ซึ่งก็หาตัว bytecode ที่เราอยากจะแก้ได้ไม่ยาก แก้เลยๆ
s = s[:108] + b'\x03' + s[109:]
test = cloudpickle.loads(s)
dis.dis(test)
2 0 LOAD_CONST 1 ((1, 2, 3))
2 UNPACK_SEQUENCE 3
4 STORE_FAST 0 (a)
6 STORE_FAST 1 (b)
3 8 LOAD_FAST 0 (a)
10 LOAD_FAST 1 (b)
12 BUILD_TUPLE 2
14 RETURN_VALUE
test()
(1, 2)
ก็จะสั่งให้มันทำงานได้ละ XD เหตุผลว่าทำไม Python ถึงยึด UNPACK_SEQ จากตัวแปรด้านซ้าย มากกว่าค่าด้านขวา ผมเองก็ไม่ทราบตรงๆเหมือนกัน แต่เดาว่า programming design มันถูกต้องกว่า เพราะจำนวนชัดเจน ไม่เหลือขยะทิ้งไว้บน stack
มี unpack อีกตัวที่น่าสนใจ และเจอบ่อยๆ ต่างกันนิดเดียวคือมีปีกกาครอบ
def test3():
a,b = {1,2}
dis.dis(test3)
2 0 LOAD_CONST 1 (1)
2 LOAD_CONST 2 (2)
4 BUILD_SET 2
6 UNPACK_SEQUENCE 2
8 STORE_FAST 0 (a)
10 STORE_FAST 1 (b)
12 LOAD_CONST 0 (None)
14 RETURN_VALUE
bytecode ที่ออกมาก็คล้ายกันมาก ต่างกันแค่ด้านบนที่เอา {1,2} ไปปั้นเป็น set ก่อน มี OP เพิ่มมา 2 บรรทัด แปลว่าในการทำงานจริงมันน่าจะทำงานช้ากว่าแหละ ใช้ tuple แบบด้านบนน่าจะดีกว่า
Swap assignment
อันนี้ก็เป็นหนึ่งใน feature ของ Python ที่น่าสนใจที่ภาษา low level ทำไม่ได้ คือไม่ต้องใช้ tmp เลย ในขณะที่หลายๆภาษาต้องไปนั่งประกาศ tmp มาช่วย
def test(a,b):
a,b = b,a
return a,b
dis.dis(test)
2 0 LOAD_FAST 1 (b)
2 LOAD_FAST 0 (a)
4 ROT_TWO
6 STORE_FAST 0 (a)
8 STORE_FAST 1 (b)
3 10 LOAD_FAST 0 (a)
12 LOAD_FAST 1 (b)
14 BUILD_TUPLE 2
16 RETURN_VALUE
อ่อ ชัดเจน มันทำได้ เพราะ OP มันไม่ได้บอกว่าให้เอาค่าจาก b มาใส่ a แต่มันคือ โหลดค่า a,b ขึ้น stack ก่อน แล้วจึง store a,b กลับลงมาที่ตัวแปร ถ้ากรณีตัวแปรหลายตัว Python จะใช้ท่าสร้าง tuple มาไว้บน stack แทน แล้วค่อยๆ pop ออกมาทีละตัว มันก็คือ tmp นั่นแหละ แค่มันอยู่บน stack ไปแล้ว
ส่วน ROT_TWO มันคือการสลับที่ค่าสองตัวบน stack จาก [a,b] เป็น [b,a] แล้วค่อย store ตามลำดับ ผมก็สงสัยนะว่าทำไมต้องเปลือง OP ROT_TWO ในเมื่อสลับลำดับ store มันก็ได้ผลเหมือนกัน ได้คำตอบที่ดีอยู่ในนี้
The rotation is needed because Python guarantees that assignments in a target list on the left-hand side are done from left to right.
เข้าใจว่า compiler standard ประมาณนั้น เพื่อไม่ให้มี undefined behavior โผล่มาในตอนหลังหรือตอนที่ function มันซับซ้อนอะนะ
เหลือขยะไว้บน stack เป็นอะไรไหม
คิดว่าเป็นแน่ๆ แต่ลองดู
ตอนแรก ผมลองท่า เอา unpack 3 ไปใส่ 2 แล้วตามด้วย ประกาศ set (ซึ่งใช้ค่าบน stack มาปั้น set)
def test():
a,b = 1,2,3
c = {4,5}
return c
dis.dis(test)
2 0 LOAD_CONST 1 ((1, 2, 3))
2 UNPACK_SEQUENCE 2
4 STORE_FAST 0 (a)
6 STORE_FAST 1 (b)
3 8 LOAD_CONST 2 (4)
10 LOAD_CONST 3 (5)
12 BUILD_SET 2
14 STORE_FAST 2 (c)
4 16 LOAD_FAST 2 (c)
18 RETURN_VALUE
s = s[:108] + b'\x03' + s[109:]
test = cloudpickle.loads(s)
test()
{4, 5}
ก็งงสักพักว่าทำไม มันถึงไม่เอาค่า 3 ที่เป็นขยะบน stack มาสร้าง set ด้วยนะ แล้วก็พบว่าผมหง่าวเอง BUILD_SET มันมี param 2 อยู่ว่าเอาแค่สองตัวเท่านั้น ขยะตัวอื่นบน stack เลยไม่นับ กำๆ ลองหาท่าอื่นว่ามี OP ไหนน่าจะมีปัญหาไหม ดูใน doc dis นั่นแหละ ก็พบว่าหลายๆตัวก็มี count ใส่เข้าไปหมด ก็ safe ดี (ถ้าใครเจอว่ามีปัญหา ก็ทักมาได้นะครับ อยากเห็นเหมือนกัน)
ถ้าผมลองเปลี่ยน BUILD_SET เป็น 3 ก็จะติดเลข 3 มาละ
s = s[:108] + b'\x03' + s[109:118] + b'\x03' + s[119:]
test = cloudpickle.loads(s)
test()
{3, 4, 5}
2 0 LOAD_CONST 1 ((1, 2, 3))
2 UNPACK_SEQUENCE 3
4 STORE_FAST 0 (a)
6 STORE_FAST 1 (b)
3 8 LOAD_CONST 2 (4)
10 LOAD_CONST 3 (5)
12 BUILD_SET 3
14 STORE_FAST 2 (c)
4 16 LOAD_FAST 2 (c)
18 RETURN_VALUE
เหลืออะไรอีก ใช่แล้ว ตระกูล closure D:
Assign with closure variables
ผมอธิบาย closure ไม่ถูกแหะ ประมาณว่ามันคือ nested function ที่ function ด้านในมี access ถึงตัวแปรนอก scope มันอีกที (แต่ไม่ใช่ global var)
def test():
a = 5
def test2():
b = a
return b
t1 = test2
yield t1
a = 6
t2 = test2
yield t2
a = test()
t1 = next(a)
print(t1())
t2 = next(a)
print(t1(),t2())
ลอง disassemble อันง่ายก่อน
def test():
a = 5
def test2():
b = a
return b
return test2
t = test()
dis.dis(t)
4 0 LOAD_DEREF 0 (a)
2 STORE_FAST 0 (b)
5 4 LOAD_FAST 0 (b)
6 RETURN_VALUE
จะเห็นว่าไม่ใช่ LOAD_CONST ละ เพราะมันไม่ใช่ const ค่าของ a ขึ้นอยู่กับว่า a ขณะนั้นมีค่าเท่าไร ซึ่งเราก็เข้าไปดูได้ใน function metadata นั่นแหละ
t.__code__.co_freevars
('a',)
t.__closure__[0]
<cell at 0x7f5bea440fa0: int object at 0x7f5c0c0acab0>
t.__closure__[0].cell_contents
5
t.__closure__[0].cell_contents = 13
t()
13
ก็สมเหตุสมผล เพราะมันเป็น reference ไม่ใช่ const
ส่วนตัว outer function จะเป็น co_cellvars
แทน
test.__code__.co_cellvars
('a',)
dis.dis(test)
2 0 LOAD_CONST 1 (5)
2 STORE_DEREF 0 (a)
3 4 LOAD_CLOSURE 0 (a)
6 BUILD_TUPLE 1
8 LOAD_CONST 2 (<code object test2 at 0x7f5bea517ea0, file "/tmp/ipykernel_209/2386031885.py", line 3>)
10 LOAD_CONST 3 ('test.<locals>.test2')
12 MAKE_FUNCTION 8 (closure)
14 STORE_FAST 0 (test2)
6 16 LOAD_FAST 0 (test2)
18 RETURN_VALUE
Disassembly of <code object test2 at 0x7f5bea517ea0, file "/tmp/ipykernel_209/2386031885.py", line 3>:
4 0 LOAD_DEREF 0 (a)
2 STORE_FAST 0 (b)
5 4 LOAD_FAST 0 (b)
6 RETURN_VALUE
จะเห็นว่าจากเดิมที่ a=5
ง่ายๆ ใช้ STORE_FAST
จะถูกเปลี่ยนเป็น STORE_DEREF
แทน แล้วก็ถ้าไม่อยากให้ ambiguous ก็ควรใส่ nonlocal ระบุไปด้วย อันนี้แนะนำตัวอย่างของกรณี ambiguous ได้ดีมาก
def outer():
var = 1
def inner():
if spam:
var = 1
var += 1
return var
return inner
น่าจะหมดเท่านี้มั้งสำหรับ OP code ตระกูล variable assignment, load, store :D