אחד מהמונחים שרק נשמעים מסובכים אבל בפועל הם פשוטים להבנה וגם קריטיים כאשר מתכננים מערכות ממוחשבות הם המונחים Couple ו-Decouple. אני אסביר על שניהם באופן פשוט ואדגים עם פייתון וג׳אווהסקריפט בדוגמאות פשוטות.
המונח Coupled מתייחס לקשר בין ״יחידות תוכנה״. תמיד כשקראתי על יחידות תוכנה כאלו, זה נשמע לי ארכאי ומיושן כאילו אני עובד על מיינפריים בשנות השבעים, אבל בגדול יחידות תוכנה יכולות להיות גם פונקציות. כלומר פונקציה שעושה משהו ופונקציה שעושה משהו אחר. יש קשר בין שתי הפונקציות האלו – אולי אחת משתמשת בתוצאה של הפונקציה השניה, אולי אחת מרחיבה את הפונקציה השניה, ווטאבר. הקשר הזה יכול להיות צמוד או לא. אני אדגים באמצעות פונקציה פשוטה. קודם דוגמה בפייתון ואז בטייפסקריפט.
לשם הדוגמה, נניח שיש לי סקריפט שמדפיס את שיעור המס מסכום מסוים.
בפייתון:
TAX_RATE = 0.17
def calculate_total(amount):
tax = amount * TAX_RATE
return amount + tax
def print_invoice(amount):
total = calculate_total(amount)
print(f"Amount: {amount}, Tax: {amount*TAX_RATE}, Total: {total}")
print_invoice(100)
והנה אותו הדבר בטייפקסקריפט:
const TAX_RATE = 0.17;
function calculateTotal(amount: number): number {
const tax = amount * TAX_RATE;
return amount + tax;
}
function printInvoice(amount: number): void {
const total = calculateTotal(amount);
const tax = amount * TAX_RATE;
console.log(`Amount: ${amount}, Tax: ${tax}, Total: ${total}`);
}
printInvoice(100);
למרות שהכתיבה קצת שונה בגלל הסינטקס, העקרון הוא אותו עקרון. יש לנו משתנה גלובלי שהוא 0.17. הפונקציה של חישוב המס לוקחת אותו ומחשבת אותו והפונקציה של ההדפסה קוראת לפונקציה של המס עם הסכום, מחשבת ומדפיסה אותו.
לכאורה, אחלה. אבל זה אומר שהפונקציות הן Coupled. ראשית, יש כאן תלות במשתנה גלובלי שזה בעייתי בפני עצמו. למשל אם אני ארצה לשנות את הפונקציה כדי שתתמוך בכמה סוגי מס (למשל גם בפירות ובירקות שהם ללא מס) אני אהיה בבעיה. אבל הפונקציה של ההדפסה צמודה לזו של חישוב המס כיוון שבתוכה יש לוגיקה שמתייחסת לפונקצית חישוב המס. בשורה של הדפסת המס. כלומר הפונקציה של ההדפסה מכירה פרטים פנימיים על אופן הפעולה של החישוב. בקוד יש שימוש בידע שהפונקציה של החישוב משתמשת במשתנה הגלובלי ואיך היא משתמשת.
הבה ונדגים עם קוד אחר. הפעם קוד ששולח הודעות. הוא יכול לשלוח במייל ויכול לשלוח גם בסמס. למשל:
CHANNEL = "email"
def send_email(message: str):
print(f"Sending EMAIL: {message}")
def send_sms(message: str):
print(f"Sending SMS: {message}")
def notify_user(message: str):
if CHANNEL == "email":
send_email(message)
elif CHANNEL == "sms":
send_sms(message)
notify_user("שלום! יש לך הודעה חדשה.")
ובג׳אווהסקריפט:
const CHANNEL = "email";
function sendEmail(message) {
console.log("Sending EMAIL:", message);
}
function sendSms(message) {
console.log("Sending SMS:", message);
}
function notifyUser(message) {
if (CHANNEL === "email") {
sendEmail(message);
} else if (CHANNEL === "sms") {
sendSms(message);
}
}
notifyUser("שלום! יש לך הודעה חדשה.");
מדובר בסקריפט קל ופשוט להבנה וגם דוברי הפייתון יבינו מעולה את מה שכתוב בג׳אווהסקריפט וההיפך. אבל יש כאן coupling חזק. למה? כי הפונקציה ששולחת הודעה למשתמש notify user, מכירה את שתי הפונקציות האחרות ואת החתימה שלהן וקוראת להן בעצמה. אם אני אשנה את החתימה או את דרך השימוש, אני אהיה בבעיה.
הבעיה של coupling היא לא בעיה פונקציונלית. הקוד יעבוד היטב. הבעיה מתרחשת כאשר אנחנו רוצים לעשות שינוי בקוד. שינוי בקוד הוא דבר שקורה כל הזמן. במקרה של המס – שר האוצר רוצה להכניס מע״מ שונה לקבוצות שונות של מוצרים או שהתחלנו למכור פירות וירקות בחנות (עליהם יש מע״מ אפס). במקרה של ההודעות למשתמש, החלטנו לאפשר הודעות גם בסלאק או לחלופין, להעמיס לוגיקה על פונקציה אחרת ואולי לשנות לה את החתימה. למשל במקרה של סמס לבדוק אם מדובר בשבת וחג ואז לא לשלוח. כאשר יש לנו coupling הדוק מדי, יש לנו בעיה בגמישות המערכת וכל דבר גורם לשבירה.
דרך טובה להתמודד עם כך זה לבצע decoupling. כלומר לגרום שפונקציה לא תדע שום דבר על הפונקציה האחרות. דרך טובה לכך היא להעביר את השם של הפונקציה שקוראים לה כפרמטר. למשל בדוגמה שלעיל, אם אני ארצה לבצע decouple, הפונקציה של notify לא תקרא ישירות לפונקציות אלא תקרא לפונקציה שאני מעביר לה. למשל, בפייתון:
from typing import Callable
def notify_user(message: str, sender: Callable[[str], None]):
sender(message)
send_email = lambda msg: print(f"EMAIL: {msg}")
send_sms = lambda msg: print(f"SMS: {msg}")
send_push = lambda msg: print(f"PUSH: {msg}")
notify_user("שלום! יש לך הודעה חדשה.", send_email)
notify_user("שלום! יש לך הודעה חדשה.", send_sms)
notify_user("שלום! יש לך הודעה חדשה.", send_push)
ובטייפסקריפט:
function notifyUser(message, sender) {
sender(message);
}
const sendEmail = (msg) => console.log("EMAIL:", msg);
const sendSms = (msg) => console.log("SMS:", msg);
const sendPush = (msg) => console.log("PUSH:", msg);
notifyUser("שלום! יש לך הודעה חדשה.", sendEmail);
notifyUser("שלום! יש לך הודעה חדשה.", sendSms);
notifyUser("שלום! יש לך הודעה חדשה.", sendPush);
מה עשינו כאן? בעצם עכשיו הפונקציה notify user לא יודעת בכלל את שמות הפונקציות האחרות שיש, אין לה מושג מי הן ומה הן. אני יכול להוסיף כמה סוגי פונקציות שאני רוצה, כמו send push או send slack או ווטאבר מבלי שאני צריך לגעת בכלל ב- notify user. זה מאפשר מערכת גמישה הרבה יותר.
זה לא כדור כסף
עניין ה decouple הוא קונספט קל להבנה ופשוט ואז… כמובן שלוקחים את זה עד הקצה ומגזימים. יש לנו את המושג loosely coupled שזה אומר שהיחידות עצמאיות מדי. זה אומר קוד מורכב ומסובך יתר על המידה. אני אדגים באמצעות פייתון עם אותו קונטקסט שבו לקחו את עניין ה-decouple לקצה והציבו אבסטרקטים לחוזה מחייב ופונקציות רבות שקובעות פורמטים ושליחות וכו׳ וכו׳. זה גורם למתכנת לשבור את הראש גם על משימה טריוויאלית. גם אם אתם לא תתעמקו בקוד הבא, מדובר בקוד ששלוח הודעה פשוטה אבל בו פירקנו את הפונקציונליות יותר מדי. בעוד שזה הגיוני לפרק למי ששולח, פה פירקו גם את הפורמט, השולח והפרוטוקול. לפעמים זה כן נדרש אבל הרבה פעמים לא.
from abc import ABC, abstractmethod
from typing import Protocol
class Message(Protocol):
def get_text(self) -> str: ...
class Sender(ABC):
@abstractmethod
def send(self, msg: Message) -> None: ...
class Formatter(ABC):
@abstractmethod
def format(self, msg: Message) -> str: ...
class OutputChannel(ABC):
@abstractmethod
def deliver(self, text: str) -> None: ...
class SimpleMessage:
def __init__(self, text: str):
self.text = text
def get_text(self) -> str:
return self.text
class PlainFormatter(Formatter):
def format(self, msg: Message) -> str:
return f"[MSG]: {msg.get_text()}"
class ConsoleChannel(OutputChannel):
def deliver(self, text: str) -> None:
print(text)
class GenericSender(Sender):
def __init__(self, formatter: Formatter, channel: OutputChannel):
self.formatter = formatter
self.channel = channel
def send(self, msg: Message) -> None:
text = self.formatter.format(msg)
self.channel.deliver(text)
msg = SimpleMessage("Hello, world!")
formatter = PlainFormatter()
channel = ConsoleChannel()
sender = GenericSender(formatter, channel)
sender.send(msg)
לסיכום, הפוסט הוא פוסט טכני אבל לא נוגע לקוד. בעידן שבו אנחנו לאו דווקא כותבים את הקוד כי יש LLM עבור זה, חשוב יותר מתמיד לחשוב על מבנה הקוד. מבנה יותר מדי צמוד, יגרום לשבירת המערכת גם בשינויים קטנים. מבנה פחות מדי צמוד יגרום לערימות קוד ולקושי בשינויים (כן, גם עם LLM). האיזון הוא המפתח ובשביל האיזון צריך… להכיר ולחשוב ואם הגעתם עד לסוף, לפחות אתם מכירים עכשיו את המונח 🙂






