งม Python bytecode — variable assignment

Bank Eakasit
5 min readApr 1, 2023

--

ในวันที่คุณเหนื่อยล้า โปรเจคป.โท ไม่คืบหน้า เลยเอาเวลาว่างมางม 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

--

--

Bank Eakasit
Bank Eakasit

No responses yet