תוכן עניינים
רוברט מרטין הוא מהנדס תוכנה אמריקאי, אחד מההוגים של הגישה 'פיתוח תוכנה זריז' (קריאה נוספת בויקיפדיה), שגורסת, בגדול – שלא ניתן להגדיר עד הסוף תוכנה בטרם הפיתוח, ועל בסיס הנחה זו מציעה עקרונות שונים שלא מתבססים על אפיון, לפיתוח מהיר.
הוא תרם רבות לעולם מדעי המחשב, עם מספר לא קטן של מאמרים שהפכו למוסכמה, ביניהם גם המאמר עליו סיפרתי – גישת 'פיתוח תוכנה זריז'.
ועד כמה שרוברט מרטין מעניין (והוא מעניין: אני ממליץ בחום לקרוא עליו ואת המאמרים שלו), הפוסט הזה הוא לא עליו. אז על מה כן? אני שמח ששאלתם.
כדי לענות על השאלה הזו, חשוב לי להקדים ולספר לכם איך התחלתי לפתח: (אל תדאגו, זה לא ארוך כמו איך פגשתי את אמא)
בגיל 14, פתחתי את הדפדפן ורשמתי בגוגל:
how to build websites
מפה לשם התגלגלתי ל – PHP ומשם הדרך לתכנות מונחה עצמים הייתה קצרה (יחסית).
למרות שלמדתי בתיכון מדעי המחשב במשך כ – 3 שנים, אני מקפיד לומר בגאווה לכל שואל שאני רכשתי את הידע שלי דרך לימוד עצמי.
אמנם אני חסיד של הגישה הזו, אבל אי אפשר להתעלם מהחסרון הגדול שבה: הלימוד לא מעוגן בתוכנית מסודרת שנכתבה על-ידי אנשי מקצוע ובעקבות כך עלולים להיווצר בורות ידע בתחומים שונים.
במסגרת המשרד אותו אני מנהל, החלטנו לאחרונה לבצע רענון טכנולוגי, ובמהלך הלמידה נתקלתי בראשי התיבות S.O.L.I.D:
SOLID? סולידי? מה?
S.O.L.I.D הינם ראשי תיבות של חמשת העקרונות הראשונים מתוך 11 עקרונות לעיצוב תוכנה מונחית עצמים, שהם חלק מגישת 'פיתוח תוכנה זריז'.
(הערת אגב: רוברט מרטין קרא לקבוצת העקרונות בשם – "חמשת העקרונות הראשונים". מיכאל פיטרס טבע את המונח S.O.L.I.D)
על-ידי יישום של כל חמשת העקרונות – ניתן להשיג רמת תחזוקה ויכולת הרחבה גבוהות – ובקלות.
חמשת העקרונות הם:
- עקרון אחריות יחידה
- עקרון יישום פתוח-סגור
- עקרון ההחלפה של ליסקוב
- עקרון הפרדה-בין-ממשקים
- עקרון היפוך תלות
הפוסט הזה הוא אחד מתוך חמישה בסדרת SOLID POSTS: הסבר מעמיק על חמשת העקרונות שתיארתי.
נקודה אחרונה לפני שיוצאים לדרך: הדוגמאות בפוסט כתובות בשפת PHP, השפה בה אני כותב לרוב,
אבל העקרונות האלה תקפים לגבי יישום של תכנות מונחה עצמים בכל שפה שהיא.
נצא לדרך – Single Responsibility, או בעברית:
עקרון אחריות יחידה
צריכה להיות סיבה אחת ויחידה לשינוי מחלקה.
על-פי עקרון אחריות יחידה, לכל מחלקה יש תפקיד אחד בלבד.
כדי להבין לעומק את הכוונה, ניקח את המחלקה מהסבר המקור של רוברט מרטין:
1 2 3 4 |
abstract class Rectangle { abstract public function draw(); abstract public function area(); } |
המחלקה מכילה שתי פונקציות:
draw
מציירת מלבן על המסך
area
מחשבת את השטח של המלבן
מכאן ניתן לומר שלמחלקה שני סוגי אחריות:
- רנדור גראפי (
draw
) - חישוב מתמטי (
area
)
עכשיו, נתאר מצב שבו שתי אפליקציות עושות שימוש במחלקה Rectangle
:
Geometric Calculator
מבצעת חישובים גיאומטריים.
נעזרת במחלקה Rectangle
כדי לבצע חישוב שטח מלבן (area
).
Geometric Drawer
מדפיסה צורות גיאומטריות.
נעזרת במחלקה Rectangle
לחישוב שטח המלבן (area
) וציורו (draw
)
כמו שבטח הבנתם, המחלקה Rectangle
מפרה את עקרון אחריות יחידה, מה שמביא לתולדה של מספר בעיות:
- בלא מעט שפות, המחלקה הגראפית שבאמצעותה הפונקציה
draw
תצייר את המלבן תיטען, גם אם לא ייעשה בה שימוש - האפליקציות שבירות: שינוי באפליקציה Geometric Drawer עשוי לגרום לשינוי במחלקה
Rectangle
, מה שעשוי להצריך בנייה מחדש של האפליקציה Geometric Calculator.
כמו במגדל קלפים, כל שינוי קטן עלול להביא לקריסה – ונצטרך לקחת את זה בחשבון בכל פעם שנשנה את אחת האפליקציות / את המחלקהRectangle
.
ריבוי תפקידים זה רע. אז מה כן?
כדי להימנע מנקודות התורפה שהזכרתי בפסקה למעלה, נבצע הפרדת סוגי אחריות לשתי מחלקות שונות:
GeometricRectangle
1 2 3 4 5 |
<?php abstract class GeometricRectangle { abstract public function draw(); } |
Rectangle
1 2 3 4 5 |
<?php abstract class Rectangle { abstract public function area(); } |
על-ידי הפרדת סוגי האחריות לשתי מחלקות שונות, ביטלנו את נקודות התורפה:
GeometricRectangle
לא תטען את המחלקה הגראפית שמציירת את המלבן- שינוי בדרך שבה מלבן מרונדר באפליקציה Geometric Drawer לא ישפיעו על האפליקציה Geometric Calculator
אבל בינינו, למי מכם יצא לאחרונה לכתוב מחלקה שמטפלת במלבן?
אני לא אוהב פוסטים שנותנים דוגמאות כלליות ופחות-רלוונטיות ומשתדל לא לחטוא לכך בעצמי.
אז בואו נלך על דוגמה שנייה, הפעם עם מחלקה שקיימת כמעט בכל מערכת – מחלקת משתמש:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 |
<?php CONST ACCEPTABLE_USER_FIELDS = array( 'username', 'email', 'password', 'gravatar' ); class User { private $id; private $userFields; private $db; public function __construct($id, $info) { $this->id = $id; $this->initializeUser($info); $this->db = new PDO(' mysql:dbname=masterdb;host=localhost', 'masteruser', 'mAst3rPassWord' ); } private function initializeUser($info) { foreach($info as $key=>$val) $this->updateField($key, $val); } public function updateField($key, $value) { if(in_array($key, ACCEPTABLE_USER_FIELDS)) { // update user field $this->userFields[$key] = $value; // update database try { $q = "UPDATE user SET {$key}='${value}' WHERE id={$this->id}"; $this->db->prepare($q)->execute(); return true; } catch(PDOException $e) { return array( 'query' => $q, 'error' => $e->getMessage() ); } } else; // error handling } public function HasAccessTo($area) { try { $areaQuery = "SELECT id FROM area WHERE name={$area}"; $areaID = $this->db->prepare($areaQuery)->fetchColumn(); if(!empty($areaID)) { try { $accessQuery = "SELECT COUNT(user_id) FROM areaAccess WHERE user_id = {$this->id} AND area_id = {$areaID}"; return $this->db->prepare($accessQuery)->fetchColumn() === 1; } catch(PDOException $e) { return exceptionHandler::pdo($e); // dummy class/method } } } catch(PDOException $e) { return exceptionHandler::pdo($e); // dummy class/method } } public function delete() { try { $q = "DELETE FROM user WHERE id={$this->id}"; $this->db->exec($q); return true; } catch(PDOException $e) { return array( 'query' => $q, 'error' => $e->getMessage() ); } } public function printUserInfo() { $output = "User ID: {$this->id}<br /> Username: {$this->userFields['username']}<br /> Email: {$this->userFields['email']}<br /> "; echo $output; } } |
בוחן פתע: כמה תפקידים יש למחלקה ומהם?
נסו לנתח את המחלקה ולהגדיר את התפקידים שלה. אני אתן לכם רמז: (עלו על החלק המטושטש)
למחלקה יש חמישה תפקידים שונים
והנה הם לפניכם:
- עדכון פרטי המשתמש (
initializeUser
,updateField
) - עדכון מסד נתונים (
updateField
,delete
) - התמודדות עם שגיאות מסד נתונים (
updateField
,delete
) - בדיקת הרשאות (
hasAccessTo
) - הדפסת פרטי המשתמש (
printUserInfo
)
ניתן להגיד – קבל עם ועדה – שהמחלקה הזו סותרת את עקרון האחריות היחידה.
ולמה זה רע? כי:
- יעילות התחזוקה של המחלקה יורדת בעקבות התלות שלה בגורמים משתנים: צורך בהדפסת פרטי המשתמש בפורמט שונה (JSON למשל), שינוי בספריית הגישה וכו'
- טעינת משאבים מיותרת: לא בכל הפונקציות נעשה שימוש במסד הנתונים; בכל הפונקציות מסד הנתונים זמין.
מחלקת המשתמשים היא מחלקה שבירה ותלויה ובעלת פוטנציאל מסוכן להפוך למסורבלת.
כל תפקיד שהמחלקה ממלאת הוא גם סיבה לשינוי המחלקה.
יקומו הספקנים ויגידו: מחלקה אחת, שחרר – יהיה בסדר..
נכון, כאן מדובר במחלקה אחת, קטנה יחסית; אבל מערכת לא בנויה רק ממחלקה אחת – היא בנויה מאוסף מחלקות שמשתנות לאורך זמן בעקבות דרישות חדשות שמצריכות פונקציות חדשות או עריכה של פונקציות קיימות.
לדוגמה, נניח שקיבלנו מהלקוח דרישה למשיכת נתוני משתמש בפורמט JSON דרך כתובת URL.
יש לנו שתי אפשרויות:
- להוסיף הסתעפות לפונקציה
printUserInfo
- ליצור פונקציה נוספת –
printUserInfoJSON
היכולת לתחזק את הקוד ביעילות ובמהירות נפגעת, והיא צורך הכרחי ותביא אתכם לחיסכון דרמטי של שעות עבודה.
אני לא אומר את זה סתם, כי אם הייתם רואים את מחלקת ה – Admin במערכת הישנה שפיתחתי, הייתם מפסיקים לקרוא את הבלוג הזה תכף ומיד.
מהלך היפרדות
כמו בדוגמה הקודמת,גם כאן נפרק את המחלקה לפי סוגי האחריות – 5 מחלקות ייעודיות שיבצעו את הפעולות הנדרשות.
הנה יישום של כל המחלקות מלבד המחלקה שמטפלת בשגיאות דטבייס – כמובן שהיישום לא מהותי והוא לצורך הדוגמה בלבד:
User
המחלקה אחראית לעדכון פרטי המשתמש
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
<?php CONST ACCEPTABLE_USER_FIELDS = array( 'username', 'email', 'password', 'gravatar' ); class User { private $id; private $userFields; public function __construct($id, $info) { $this->id = $id; $this->initializeUser($info); } private function initializeUser($info) { foreach($info as $key=>$val) $this->updateField($key, $val); } public function updateField($key, $value) { if(in_array($key, ACCEPTABLE_USER_FIELDS)) // update user field $this->userFields[$key] = $value; else // error handling } } |
userDB
המחלקה אחראית לעדכון נתוני משתמש במסד הנתונים.
* המחלקה מיישמת את הממשק UserDBLogic
ומרחיבה את המחלקה הדמיונית DB
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 |
<?php interface UserDBLogic { public function select(Integer $id); public function update(Integer $id, Array $fields); public function insert(Integer $id, Array $fields); public function remove(Integer $id); } class UserDB implements UserDBLogic { private $db; public function __construct($db) { $this->db = $db; } public function select(Integer $id) { try { $q = "SELECT * FROM user WHERE id = {$id}"; $statement = $this->db->prepare($q); $statement->setFetchMode(PDO::FETCH_ASSOC); return $statement->fetchAll(); } catch(PDOException $e) { return exceptionHandler::pdo($e); // dummy class/method } } public function update(Integer $id, Array $fields) { try { $q = "UPDATE user SET"; foreach($fields as $field=>$value) { $q .= $field . '=' . $value . ','; } $q = rtrim($q, ',') . " WHERE id = {$id}"; $this->db->prepare($sql); $this->db->execute(); return true; } catch(PDOException $e) { return ExceptionHandler::pdo($e); // dummy class/method } } public function insert(Array $fields) { try { $fieldsStr = implode(', ', array_keys($fields)); $valuesStr = implode(', ', array_values($fields)); $q = "INSERT INTO user ({$fieldsStr}) VALUES({$valuesStr})"; $this->db->exec($q); return true; } catch(PDOException $e) { return ExceptionHandler::pdo($e); } } public function remove(Integer $id) { try { $q = "DELETE FROM user WHERE id={$id}"; $this->db->exec($q); return true; } catch(PDOException $e) { return ExceptionHandler::pdo($e); } } } |
userAccess
המחלקה אחראית לבדוק האם למשתמש יש הרשאת גישה לאיזור מסויים.
(תודה לקורא אלכס רסקין שהראה מה הדרך הנכונה לכתוב את המחלקה)
1 2 3 4 5 6 7 8 9 10 11 12 13 |
<?php class UserAccess { private $greenAreas; public function __construct($greenAreas) { $this->greenAreas = $greenAreas } public function HasAccessTo($area) { return in_array($area, $this->greenAreas); } } |
הגדרת אחריות
רוברט מרטין הגדיר אחריות (בהקשר של עקרון אחריות יחידה) כ – "סיבה לשינוי".
הוא גם הזהיר מפני האינסטינקט האנושי שגורם לנו לחשוב על אחריות בקבוצות, למשל: האחריות של מסך היא "להציג תמונה" – אבל תחת האחריות הזו יש הרבה תתי-פריטים, שנפרדים אחד מרעהו לגמרי:
- בהירות
- ניגודיות
- הגדרות אנרגיה
- הגדרות תצוגה
- …
איפה זה נגמר?
ניתן לטעון שזה לעולם לא נגמר. תמיד אפשר לפרק אחריות אחת לאינספור סוגי אחריות.
וכאן, לדעתי – מגיע החלק החשוב ביותר בעקרון אחריות יחידה:
ניקח לדוגמה את הממשק הבא (נכתב על-ידי רוברט מרטין), שמתאר מודם:
1 2 3 4 5 6 7 8 |
<?php interface Modem { public function dial(String $pno); public function hangUp(); public function send(String $c); public function receive(); } |
באופן טבעי, בהסתכלות ראשונה – הממשק הזה מתאר אחריות אחת – של מודם.
אבל – בהסתכלות מעמיקה יותר כשעקרון אחריות יחידה נמצא בראש – יש לממשק הזה שני תפקידים:
- חיבור (
dial
,hangUp
) - תקשורת (
send
,recieve
)
בואו נעצור לרגע ונחשוב – האם כדאי לנו לפרק את הממשק הזה לשני ממשקים שונים?
התשובה תלויה באופן שבו המודם עשוי להשתנות, וזו גם השאלה שעליכם לשאול את עצמכם כדי להחליט האם להפריד מחלקה לפי תפקידים:
האם הסיבה לשינוי תיתכן במציאות?
אם התשובה היא לא חד משמעי – אין סיבה אמיתית ליישם את עקרון אחריות יחידה.
חשוב לי להבהיר את הנקודה של הפסקה הזו: אין כלל אצבע שבאמצעותו ניתן להחליט אם להפריד מחלקה לסוגי אחריות או לא.
החכמה היא לאמץ את צורת המחשבה שעומדת מאחורי העקרון מצד אחד, אבל מצד שני לדעת מתי הוא מתאים ומתי היתרונות שלו לא יכולים לבוא לידי ביטוי.
סיכום וטיפ בונוס
רוברט מרטין כתב בסיכום עקרון אחריות יחידה: (תרגום חופשי)
עקרון אחריות יחידה הוא אחד מהעקרונות הפשוטים ביותר שקיימים, ואחד מהעקרונות הקשים ביותר ליישם נכון.
איחוד אחריות נעשה על-ידי בני האדם באופן טבעי. מציאת והפרדת אחריות כפולה – זה אחד האתגרים שטמונים בעיצוב תוכנה.
יתר העקרונות מבוססים בצורה כזו או אחרת על עקרון אחריות יחידה.
ולי לא נותר אלא להסכים: בדומה ל – MVC, עקרון אחריות יחידה הוא שינוי תפיסתי בעיקרו.
אבל אחרי שתתחילו ליישם אותו – לא תוכלו ללכת אחורה.
הבטחתי בונוס, שהוא בעצם טיפ:
מצאתי שיישום עקרון אחריות יחידה רלוונטי לא רק במחלקות, אלא גם בפונקציות.
הפרידו אחריות כפולה למס' פונקציות שישמרו את הקוד נקי, קצר, חזק וקל לתחזוקה.
נתראה בפוסט השני בסדרה: עקרון יישום פתוח סגור!
מקורות
- מאמר המקור מאת רוברט מרטין


4 תגובות
ronapelbaum
16 במרץ 2017 - 9:06פוסט מצויין!
מחכה כבר לפוסט הבא!
שאלה/המלצה – למה שלא תכתוב את הדוגמאות בפסאודו קוד? יחסוך הרבה שורות ויהיה יותר קריא לכולם (אני אישית לא דובר PHP)
יונתן נקסון
19 במרץ 2017 - 11:41תודה רון! 🙂
אתה מוזמן לקרוא את המאמר השני בסדרה (עקרון יישום פתוח-סגור):
http://masterscripter.co.il/%D7%A2%D7%A7%D7%A8%D7%95%D7%9F-%D7%99%D7%99%D7%A9%D7%95%D7%9D-%D7%A4%D7%AA%D7%95%D7%97-%D7%A1%D7%92%D7%95%D7%A8/
בנוגע לדוגמאות, אני חושב שהמסר מועבר יותר טוב כשהן כתובות בשפת תכנות לעומת פסאודו קוד.
אני משתדל להשתמש במבנים וקטעי קוד שיהיו מובנים גם למי שלא כותב ספציפית בשפת הדוגמה
וגם מקפיד לגוון את השפות בהן אני משתמש (במאמר השני השתמשתי ב – TypeScript בעיקר).
Rachel
15 בספטמבר 2020 - 23:19נהנתי מאוד!
ברור אפילו שאיני דוברת השפה:)
עדי
18 בנובמבר 2021 - 9:17מעניין מאד, באמת מרענן ומבהיר את הדברים !
אולי כדאי להפוך את הרשימה של 5 העקרונות בראש המאמר לקישורים למאמרים עליהם, כך יהיה יותר קל לנווט הלאה.
תודה!