Functional Programming in Python Part 3: Functional Programming Library (toolz)

RuufimoN
odds.team
Published in
3 min readDec 18, 2023

VDO อยู่ตรงนี้ -> Youtube
Slide อยู่ตรงนี้ -> PowerPoint
Code ตัวอย่างอยู่ตรงนี้ -> Github

ภาคที่แล้วเราทำ function composition ด้วยมือเราเองไปแล้วและเราพบว่า โอยทรมานอย่างไรก็ตาม การทำเองด้วยมือก็ทำให้เราเข้าใจ concept และที่มาที่ไปได้อย่างชัดเจน แต่ว่าตอนทำงานจริงก็จะไม่มีคนไปทางนี้กันเพราะว่า python เองก็มี lib หลายตัวที่เตรียมความสามารถเรื่องของการทำ functional programming ไว้ให้แล้วและหนึ่งในนั้นคือ toolz หรือว่าชื่อจริงคือ PyToolz

PyToolZ (pipe and curry)

เป็น library ที่อัดแน่นไปด้วยเครื่องไม้เครื่องมือเรื่อง functional programming ไม่ว่าจะเป็นเรื่อง composable, purity และ laziness แล้ววันนี้ขอยังไม่พูดเรื่อง purity และ laziness นะครับเพราะเดี๋ยวออกทะเลไปไกล วันนี้เราเน้นเรื่อง composable ก่อนการที่เราจะ compose ของด้วย toolz ได้นั้นง่ายมากเราแค่ใช้ pipe แล้วใส่ unary function ที่เราต้องการ compose เข้าไปเท่านี้ จอบอ !!!!! 6_compose_toolz_example.py


from toolz import pipe

# Define unary functions
def add_two(x):
return x + 2

def multiply_by_three(x):
return x * 3

def subtract_five(x):
return x - 5

# Compose functions
basic = subtract_five(multiply_by_three(add_two(2)))
# Compose functions using pipe
result = pipe(2, add_two, multiply_by_three, subtract_five)

นอกจากนี้แล้วถ้าเราต้องการเปลี่ยน binary funciton ให้เป็น unary function ก็สามารถใช้ currying ที่ toolz เตรียมมาให้ได้อย่าง ง่ายๆแบบนี้

from toolz import curry

@curry
def convert_to(converter, data):
try:
return [converter(item) for item in data]
except ValueError as e:
return None

score_as_float = convert_to(float)

ดังนั้นเมื่อเราเอาโปรแกรมที่เราใช้ตั้งมาแปลงร่างด้วยการใช้ toolz เราก็จะได้ของที่ดูสวยงาม ตามท้องเรื่องราวๆนี้ รายละเอียดทั้งหมดอยู่ในไฟล์ 7_0_data_toolz.py

from toolz import curry, pipe

# Function to calculate average
# Detial
# .....
# .....
# Data pipeline

average_result = pipe(
read_csv_file(csv_file_path),
extract_score_column,
removed_header,
score_as_float,
calculate_average
)

print(f"An average score is {average_result}")

มาถึงตรงนี้เท่าที่เราเห็นคือทุกอย่างเข้าใกล้สิ่งที่เราอยากได้มากๆ อย่างไรก็ตามไอ้ที่เราทำมาทั้งหมดมันเป็น happy path !!!!!! อะไรจะเกิดขึ้นถ้ามันเกิดข้อผิดพลาดระหว่างที่โปรแกรมเรากำลังทำงานเช่น หาไฟล์ไม่เจอ ดังนั้นอย่ารอช้าเลยเรามาลองกันสิด้วยการแก้ให้เป็นไฟล์ที่ไม่มีอยู่จริงแล้วลองให้โปรแกรมทำงาน

Traceback (most recent call last):
File "/Users/ruf/Workspace/Python/Fp/7_0_data_toolz.py", line 53, in <module>
average_result = pipe(
^^^^^
File "/Users/ruf/Workspace/Python/Fp/functional/lib/python3.11/site-packages/toolz/functoolz.py", line 628, in pipe
data = func(data)
^^^^^^^^^^
File "/Users/ruf/Workspace/Python/Fp/functional/lib/python3.11/site-packages/toolz/functoolz.py", line 304, in __call__
return self._partial(*args, **kwargs)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/ruf/Workspace/Python/Fp/7_0_data_toolz.py", line 20, in extract_column
return [row[column_index] for row in data]
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
TypeError: 'NoneType' object is not iterable

#$%^&#$%^ พัง !!!!! แน่นอนมันจะไม่พังได้ไง เพราะทุกฟังก์ชั่นของเราตอนที่เกิด exception เรา reutn None และที่สำคัญตรง data pipeline เราก็ไม่ได้จัดการ error ดีๆด้วยดังนั้นถ้าเราต้องการจัดการ error ดีๆเราต้องทำไงนะ

Error Handling ด้วย toolz และ standard python style

อย่างที่เรารู้กันอยู่แล้วว่า toolz มันเป็น lib ที่เตรียมความสามารถพึ้นฐานเรื่อง functional programming ให้เราอย่างเดียวมันไม่ได้เตรียมการจัดการ Error แบบ functional ให้เราด้วยดังนั้นถ้าเราต้องการจัดการเราก็ต้องไปด้วยวิธีการของ python ปกติซึ่งก็ต้องไปท่า raise Error แบบนี้ 8_error_toolz.py

from toolz import curry

@curry
def divide(a, b):
try:
result = a / b
return result
except ZeroDivisionError as e:
raise ValueError(f"Error: {e}")

try:
result = devide(10)(2) # Result: 5.0
result = divide(10)(0) # Raises ValueError: Error: division by zero
except ValueError as e:
print(f"Error handling: {e}")

และเมื่อเราเอาสิ่งนี้ไปใส่ในโปรแกรมหลักของเรา เราก็จะได้สิ่งนี้ออกมา รายละเอียดอยู่ในไฟล์ 9_0_data_try_with_toolz.py

import csv
from toolz import curry, pipe
from typing import Final

# Extract 3 functions that do one thing and do it well
# ....
# ....
# Function to calculate average
def calculate_average(column_values):
try:
return sum(column_values) / len(column_values)
except ZeroDivisionError as e:
raise ZeroDivisionError(f"Error zero division: {e}")

#============== Data pipeline ================================
# ...
# ...

try:
average_result = pipe(
read_csv_file(csv_file_path),
score_column,
remove_header,
convert_score_to_float,
calculate_average
)
print(f"An average score is {average_result}")
except (FileNotFoundError, ValueError, IndexError, ZeroDivisionError) as e:
print(f"Exception caught: {e}")

และถ้าเราของแก้ให้เกิดข้อผิดพลาดแล้วสั่งให้มันทำงานเราจะได้สิ่งนี้

Fp git:(main) ✗ /Users/ruf/Workspace/Python/Fp/functional/bin/python /Users/ruf/Workspace/Python/Fp/9_0_data_try_with_toolz.py
Exception caught: Error reading file: [Errno 2] No such file or directory: '1example.csv'

ไม่พังและทำงานจนจบแล้วแสดง Error ตรงนี้สำหรับการทำงานทั่วไปผมว่า พอเพียงและเพียงพอแล้วแต่!!!! ยังขัดใจนะ ถ้าเราจะไปให้สุดทางอย่าง Scala เท่านี้ยังพอไม่ได้

Limitation of toolz

ถึงตรงนี้เราจะเห็นได้ละว่า toolz ช่วยเราได้เยอะมากเรื่อง currying และ function composition แต่ว่าสิ่งที่ toolz ทำให้เราไม่ได้คือการจัดการ error แบบ functional way เพราะเราต้องปล่อยให้ code ส่วนของ data pipeline มี try — except มาครอบไว้ ซึ่งนี้เป็นสิ่งที่เราไม่ต้องการเราต้องการให้โค้ดของเรามีแค่ส่วนของ function composition และตอนจบเรามาดูว่าผลที่ออกมาเป็น OK หรือ Fail แล้วเราค่อยมาจัดการมันทีหลัง

  // Data pipeline in Scala
val csvFilePath = "example.csv"

val result: Try[Double] =
readCsvFile(csvFilePath)
.flatMap(data => extractColumn(1, data))
.flatMap(calculateAverage)

result match {
case Success(avg) => println(s"The average is: $avg")
case Failure(err) => println(s"Error processing data: ${err.getMessage}")
}

แน่นอนว่าสิ่งที่เราต้องการเพิ่มเพื่อให้ไปถึงจุดนี้ได้คือ Monad

ภาคต่อ

Part 4 : Monad !!!!!

--

--

RuufimoN
odds.team

ชายวัยกลางคน มีเมียหนึ่งคน ลูกสาวสองคน นกสี่ตัว