Tutorial

เขียน Code ใน Python ให้หรูหราหมาเห่าด้วย List Comprehension

By Arnon Puitrakul - 15 November 2020 - 3 min read min(s)

เขียน Code ใน Python ให้หรูหราหมาเห่าด้วย List Comprehension

หลาย ๆ คนที่เขียน Python น่าจะเคยได้ยินคำว่า List Comprehension กันมาบ้างแล้ว เราบอกเลยว่ามันเป็นวิธีการเขียนที่ทำให้ Code ของเราสั้นลงมาก ๆ และ ในบางท่า มันทำให้เราใช้ Memory น้อยลงได้ด้วยนะ วันนี้เราจะมานำเสนอ ท่าในการทำ List Comprehension ตั้งแต่ง่าย ๆ จนไปแบบเทพ ๆ กัน

อ่อน ๆ เบ ๆ ใช้แทน Loop

ท่าแรก เป็นท่าที่ง่ายที่สุดเลยคือ การใช้มันแทน Loop เพื่อสร้าง List จากข้อมูล เช่น เราอาจจะอยากได้ เลขยกกำลัง 2 ของแต่ละตัวเลข 1,2,3 ไปเรื่อย ๆ จนถึง 10 จากเดิมเราอาจจะต้องเขียนเป็นแบบด้านล่างนี้

squares = []
for i in range (1,10) :
	squares.append(i*i)

ใน Code ด้านบนนี้เราทำง่าย ๆ เลยคือ เราสร้าง List สำหรับเก็บผลลัพธ์มาตัวนึง และเราสั่ง For Loop ตั้งแต่ 1-10 โดยที่ในแต่ละรอบเราให้มันเอาตัวเลขมายกกำลังสองแล้ว ใส่ลงไปใน List ที่เราสร้าง เราใช้ทั้งหมด 3 บรรทัด แต่ถ้าเราใช้ List Comprehension จะเป็นยังไงลองมาดู

squares = [i*i for i in range(1,10)]

ใช่แล้ว ทั้งหมด เราก็จะใช้แค่บรรทัดเดียวเท่านั้นเอง ลองอ่านดูดี ๆ เราจะเห็นเลยว่า การใช้ List Comprehenision มันทำให้ Code ของเรามันดู Clean ขึ้นเยอะเลย ไม่ต้องมานั่งเปิด For Loop รก ๆ เปลือง Indent เต็มไปหมด

สำหรับใครที่คุ้นเคยกับ Functional Programming มาก่อนแล้ว จริง ๆ แล้วการที่เราทำแบบนี้เทียบกับ Functional Programming ก็คือ Map Function นั้นเอง

Conditional Filtering

เพิ่มความเทพเข้าไปอีก เราสามารถที่จะ Filter ของต่าง ๆ ด้วยการใช้ List Comprehension ได้ด้วยอีกเหมือนกัน ตัวอย่างเช่น เราอยากได้สระจากประโยคว่ามีสระตัวไหนบ้าง ถ้าเราเขียนแบบทั่ว ๆ ไป ก็น่าจะออกมาเป็นแบบด้านล่าง

sentence = 'Hello World!!'
vowels = []
for character in sentence :
	if character in 'aeiou' :
    		vowels.append(character)

เราจะเห็นได้เลยว่ามันใช้หลายบรรทัดมาก ๆ กว่าจะเขียนออกมาได้ เปลือง อ่านยาก ปวดหัว เราลองมาดูว่า ถ้าเราใช้ List Comprehension จะเป็นยังไง

sentence = 'Hello World!!'
vowels = [character for character in sentence if character in 'aeiou']

จอบอจ๊ะ จบที่ 1 บรรทัดเช่นเคย อันนี้อาจจะดูยาว และ งง ขึ้นนิดหน่อย เพื่อให้มันง่ายขึ้น เราอยากให้มองออกเป็น 3 ก้อนคือ character ก้อนแรกคือค่าที่เราจะยัดใส่ลงไปใน List ใหม่ ก้อนต่อไปคือ ส่วนของ For Loop และ ส่วนสุดท้ายคือส่วนของ Conditional

ทำให้เราสามารถที่จะ Map ค่า จากหัวข้อก่อนหน้า พร้อมกับ Filter โดยใช้เงื่อนได้พร้อม ๆ กันได้เลย ตัวอย่างเช่น เราอาจจะเอาราคาสินค้าที่เดิน 20 บาท พร้อมกับบวก VAT ออกมา

price_list = [20,50,10,40]
vat_list = []

for price in price_list :
	if price > 20 :
    	vat_list.append(price * 1.07)
        

Code ทำงานตรง ๆ เลยคือ เรา Loop ใน Price List และ ทุก ๆ รอบเราเช็คว่าถ้าราคาเกิน 20 บาท เราก็จะให้มันคูณด้วย 1.07 ก็คือ 7% นั่นเอง และ ยัดใส่ vat_list เข้าไปเป็นอันจบ ใช้หลายบรรทัดรก มาทำให้มันสั้นกัน

price_list = [20,50,10,40]
vat_list = [price*1.07 for price in price_list if price > 20]

Reducing

ตัวอย่างก่อนหน้า เรามี Map และ Filter ไปแล้ว ถึงตา Reduce กันบ้างแล้ว ตัวอย่างเช่น เรามี Matrix อยู่ชุดนึง เราอยากได้ ค่าที่มากที่สุดในแต่ละ Row เราเริ่มจาก Generate Matrix ออกมากันก่อน ทำง่าย ๆ Random มันออกมาละกัน

>> import numpy as np
>> matrix = np.random.randint(10, size=(10,10))

array([[4, 3, 4, 9, 4, 0, 7, 2, 5, 2],
       [4, 4, 2, 9, 6, 3, 6, 3, 3, 7],
       [3, 6, 8, 7, 7, 5, 5, 3, 3, 6],
       [0, 6, 0, 3, 3, 4, 9, 0, 4, 9],
       [0, 8, 9, 4, 8, 4, 5, 8, 2, 1],
       [7, 5, 9, 7, 1, 8, 5, 2, 7, 7],
       [8, 4, 2, 3, 5, 0, 0, 5, 7, 3],
       [4, 6, 5, 7, 5, 2, 0, 1, 7, 7],
       [0, 4, 6, 7, 3, 0, 0, 2, 7, 8],
       [4, 7, 2, 7, 9, 2, 3, 9, 2, 1]])

เราก็จะได้ Matrix ขนาด 10x10 ออกมา เราจะเห็นว่า มันเป็น List ซ้อน List กันอยู่ ถ้าเราอยากจะได้ค่า Max ของแต่ละ Row เราก็ต้องมา Loop เพื่อหาค่าที่มากที่สุดออกมา

max_per_row = []

for row in matrix :
   max_per_row.append(max(row))

จริง ๆ มันก็ดูไม่ต่างจากตัวอย่างแรก ๆ เท่าไหร่เนอะ เราแค่ Loop เข้าไปที่แต่ละ Row ในนั้นเราก็ Loop ไปที่ตัวเลขแต่ละตัวในแถว เราก็เทียบหาอันที่มากที่สุดออกมาและยัดใส่ที่ List ที่ชื่อว่า max_per_row ก็เป็นอันเสร็จ หลายบรรทัดอยู่ เราก็จะได้ List ขนาด 10 Element ออกมา

max_per_row = [max(row) for row in matrix]

เราก็จะได้ผลลัพธ์เดียวกันเลย แต่สั้นลงเยอะมาก ๆ

ประหยัด Memory ด้วย Generator

เพิ่มความ Advance เปรี้ยวจี๊ดเข้าไปอีก ด้วยการแปลงให้มันกลายเป็น Generator ไปแทน จากเดิมที่เราใช้ List Comprehension ปกติ เวลาเราสั่งรัน มันจะคำนวณและสร้าง List ที่เป็นผลลัพธ์ออกมาให้เราเลย ซึ่งนั่นก็คือ มันต้องไปจอง Memory สำหรับการเก็บทั้งก้อนนั้นลงไป

ปัญหามันจะมาเมื่อ List ที่เราต้องการสร้างมันใหญ่มาก ๆ มันจะใช้เวลาในการทำงานนานมาก ๆ หรือเผลอ ๆ ถ้าใหญ่มาก ๆ ระบบก็รับไม่ไหวก็จอบอไปก็มีมาแล้ว การใช้ Generator มันมาช่วยตรงนี้แหละ โดยที่มันจะค่อย ๆ ทำเมื่อเราเรียกมันออกมา หรือในภาษา Programming เราเรียกว่า Lazy Evaluation

a = [large list]
result = []

for item in a :
	result.append(process(item))

for item in result :
	my_file.write(item + '\n')

ตัวอย่างนี้คือ เราบอกว่า a เป็น List ที่มีขนาดใหญ่มาก ๆ เช่นอาจจะมีตัวเลขอยู่ 1000 ล้านตัว และเราต้องการที่จะ Process มัน อาจจะเอามาบวกกับค่าอะไรบางอย่าง และ สุดท้ายเราต้องการที่จะเขียนลงไปในไฟล์ ถ้าเราทำแบบด้านบนแปลว่า Python มันต้องจอง Memory เพื่อที่จะเก็บทั้ง a และ result เลย ทำให้ถ้าเราใช้งานกับเครื่องที่มี Memory น้อย ๆ นี่คือ จอบอ แน่นอน

a = [large list]
result = (process(item) for item in a)

for item in result :
	my_file.write(item + '\n')

ดูเผิน ๆ อาจจะเป็นแค่การลดจำนวนบรรทัด แต่บอกเลยว่าไม่ใช่ ถ้าเป็นอันแรก เราจะต้อง Evaluate ทั้งก้อนของ a ยัดลง result โดยที่ a มันเป็น List ที่ใหญ่มาก ๆ แล้วค่อย ๆ อ่านจาก result เขียนลงไฟล์ทีละบรรทัด แต่อันนี้เราแปลงให้มันเป็น Generator คือ ตรง result ตอนเราสั่ง result = ตรงนี้มันจะยังไม่คำนวณของใน result ออกมา ถ้าเราลอง print (result) ออกมา เราจะไม่ได้ List ออกมา แต่มันจะได้เป็น Generator ออกมาแทน ทำให้ Python ไม่ต้องไปจอง Memory สำหรับการเก็บ Result ทั้งก้อนลงไป ลดการเก็บ Memory ได้เท่าตัวเลย

สุดท้าย เราก็เอามันมาเข้า For Loop ตอนนี้แหละ ที่ค่าใน Generator จะค่อย ๆ ถูกคำนวณขึ้นมาทีละค่าเรื่อย ๆ และเขียนลงไฟล์ ทำให้ทั้งหมด result อยู่ใน Memory จริง ๆ แค่ตัวเดียว คือตัวล่าสุดที่เรียกใส่ลงไปใน item พอมันขึ้นรอบใหม่ item มันก็จะแทนที่ด้วย ตัวต่อไป นั่นเอง

สรุป: List Comprehension ของเทพ

จากตัวอย่างที่เราเอามาให้ดูในวันนี้ จะเห็นได้ว่า Core หลัก ๆ ของ List Comprehension คือการทำให้ตัว Code มัน Clean ไม่รก อ่านได้ง่ายขึ้น แต่สิ่งที่มันได้ตามมาคือ ทำให้เราไปโฟกัสที่ตัวข้อมูล และ Logic ของโปรแกรมได้ง่ายขึ้น แทนที่จะต้องมาระแวงเรื่อง Flow Control ซึ่งจริง ๆ มันก็คือ Concept ของ Functional Programming เลย ที่เรายกตัวอย่างมามีหมดเลยตั้งแต่ Reduce, Filter และ Map ก็สามารถเอาไปใช้งานกันได้

BONUS: ปั่นให้รันเร็วแง่น ๆ ด้วย Generator

นอกจากที่ Generator และ Lazy Evaluation จะทำให้ใช้ Memory น้อยลงได้แล้ว เรายังสามารถเอามาใช้เพื่อเร่งการทำงานของโปรแกรมด้วยเทคนิคการเขียนโปรแกรมแบบ Parallel ได้ด้วย

Python Global Interpreter Lock แบบเข้าใจง่าย?
Global Interpreter Lock (GIL) ใน Python ถือเป็นเรื่องที่เป็นศัตรูกับการทำ Multithread ใน Python เป็นอย่างมาก วันนี้เรามาทำความรู้จักกับมันกัน

หลักการก็คือ เราสร้าง Generator ที่มันจะไม่คำนวณค่าออกมาเลย เพราะมันทำแบบ Lazy Evaluation เอาไว้ก่อน แล้วเราก็กระจาย Generator หลาย ๆ อันไปตาม Thread ต่าง ๆ หรือจะใช้พวก Multiprocess เพื่อหลีกเลี่ยง GIL ใน Python ได้

เรามาลองกัน ในตัวอย่างนี้ เราจะทำง่าย ๆ คือ เรามีตัวเลขอยู่ 1000 ล้านตัว และ เราต้องการผลลัพธ์ของตัวเลข เมื่อเราคูณด้วย 2,3,4 เก็บลงไปในไฟล์ เราจะเริ่มจากการ Random ตัวเลขอีกเช่นเคย

import numpy as np
num_list = np.random.randint(200, size=(1000000000))

ถ้าเราไม่ใช้ List Comprehension เลย และคิดเยอะ ๆ หน่อย เราจะต้องทำแบบด้านล่างนี้

for item in num_list :
   file_mul_2.write(str(item*2) + '\n')
   file_mul_3.write(str(item*3) + '\n')
   file_mul_4.write(str(item*4) + '\n')

เราจะเห็นว่า ทั้งหมดนี้มันทำงานอยู่ในแบบ Single Thread เรามาลองทำให้มันเป็น Generator และ ยัดไปตาม Thread กันดีกว่า เราเริ่มจากการสร้าง Generator สำหรับแต่ละไฟล์ เพื่อความง่าย เราจะสร้างออกมาเป็น Dictionary โดยที่ Key เป็น File Path สำหรับผลลัพธ์ และ Value เป็น Generator ละกัน (ใครจะ Advance Filter ค่าไปด้วยก็เอาที่สบายใจเลย ทำได้เหมือนกัน)

process_list = {
   'path_2' : (item*2 for item in num_list),
   'path_3' : (item*3 for item in num_list),
   'path_4' : (item*4 for item in num_list)
}

จากนั้นเราก็จะสร้าง Function สำหรับการเขียนข้อมูลลงไปในไฟล์

def write_item_to_file (file_path, generator) :
   my_file = open(file_path, 'w')
   for item in generator :
       my_file.write(str(item) + '\n')
   my_file.close()

Function นี้ทำงานง่ายมาก ๆ คือ เรารับ File Path ซึ่งก็คือที่เก็บผลลัพธ์ เราก็เปิดไฟล์ และเรียกใช้งาน Generator เหมือนกับตัวอย่างก่อนหน้านี้ทั่ว ๆ ไปเลย ระหว่างนั้นเราก็เขียนผลลัพธ์ลงไฟล์ หมดก็ปิดไฟล์เป็นอันจบ

Parallelise Python แบบปลอม ๆ ด้วย Joblib
จากตอนที่แล้ว ที่เราพูดถึงเรื่อง GIL ที่ทำให้มันเป็นปัญหาในการเขียนโปรแกรมแบบ Parallel วันนี้เรามาทำความรู้จักกับ Parallel ใน Joblib ที่ทำให้เราเลี่ยง GIL ได้กัน

ด้านล่างนี้แหละ เราก็จะเอาส่วนประกอบนี้มาโยนใส่หลาย ๆ Process ด้วย Joblib ถ้าใครที่ไม่คุ้นเคย เราเคยเขียนเรื่องนี้ไว้แล้วเข้าไปอ่านก่อนได้

from joblib import Parallel, delayed

Parallel(n_jobs=-1, prefer="processes")(
            delayed(write_item_to_file)(key, item)
            for key,item in process_list
    )

เท่านี้เราก็สามารถ Parallel งานทั้งหมดไปตาม Process ต่าง ๆ ได้แล้ว ในตัวอย่างนี้อาจจะยังใช้เวลาต่างกันไม่มาก แต่ถ้าใช้งานจริงแล้วเรามีมากกว่านี้ ก็คือมันเห็นผลเลยว่าการทำแบบนี้เร็วกว่าแน่ ๆ กับทำให้ Code อ่านง่ายขึ้นเยอะ