רוברט מרטין הוא מהנדס תוכנה אמריקאי, אחד מההוגים של הגישה 'פיתוח תוכנה זריז' (קריאה נוספת בויקיפדיה), שגורסת, בגדול – שלא ניתן להגדיר עד הסוף תוכנה בטרם הפיתוח, ועל בסיס הנחה זו מציעה עקרונות שונים שלא מתבססים על אפיון, לפיתוח מהיר.
הוא תרם רבות לעולם מדעי המחשב, עם מספר לא קטן של מאמרים שהפכו למוסכמה, ביניהם גם המאמר עליו סיפרתי – גישת 'פיתוח תוכנה זריז'.
ועד כמה שרוברט מרטין מעניין (והוא מעניין: אני ממליץ בחום לקרוא עליו ואת המאמרים שלו), הפוסט הזה הוא לא עליו. אז על מה כן? אני שמח ששאלתם.
כדי לענות על השאלה הזו, חשוב לי להקדים ולספר לכם איך התחלתי לפתח: (אל תדאגו, זה לא ארוך כמו איך פגשתי את אמא)
בגיל 14, פתחתי את הדפדפן ורשמתי בגוגל:
how to build websites
מפה לשם התגלגלתי ל – PHP ומשם הדרך לתכנות מונחה עצמים הייתה קצרה (יחסית).
למרות שלמדתי בתיכון מדעי המחשב במשך כ – 3 שנים, אני מקפיד לומר בגאווה לכל שואל שאני רכשתי את הידע שלי דרך לימוד עצמי.
אמנם אני חסיד של הגישה הזו, אבל אי אפשר להתעלם מהחסרון הגדול שבה: הלימוד לא מעוגן בתוכנית מסודרת שנכתבה על-ידי אנשי מקצוע ובעקבות כך עלולים להיווצר בורות ידע בתחומים שונים.
במסגרת המשרד אותו אני מנהל, החלטנו לאחרונה לבצע רענון טכנולוגי, ובמהלך הלמידה נתקלתי בראשי התיבות S.O.L.I.D:
S.O.L.I.D הינם ראשי תיבות של חמשת העקרונות הראשונים מתוך 11 עקרונות לעיצוב תוכנה מונחית עצמים, שהם חלק מגישת 'פיתוח תוכנה זריז'.
(הערת אגב: רוברט מרטין קרא לקבוצת העקרונות בשם – "חמשת העקרונות הראשונים". מיכאל פיטרס טבע את המונח S.O.L.I.D)
על-ידי יישום של כל חמשת העקרונות – ניתן להשיג רמת תחזוקה ויכולת הרחבה גבוהות – ובקלות.
חמשת העקרונות הם:
הפוסט הזה הוא אחד מתוך חמישה בסדרת SOLID POSTS: הסבר מעמיק על חמשת העקרונות שתיארתי.
נקודה אחרונה לפני שיוצאים לדרך: הדוגמאות בפוסט כתובות בשפת PHP, השפה בה אני כותב לרוב,
אבל העקרונות האלה תקפים לגבי יישום של תכנות מונחה עצמים בכל שפה שהיא.
נצא לדרך – Single Responsibility, או בעברית:
צריכה להיות סיבה אחת ויחידה לשינוי מחלקה.
על-פי עקרון אחריות יחידה, לכל מחלקה יש תפקיד אחד בלבד.
כדי להבין לעומק את הכוונה, ניקח את המחלקה מהסבר המקור של רוברט מרטין:
abstract class Rectangle {
abstract public function draw();
abstract public function area();
}
המחלקה מכילה שתי פונקציות:
draw
מציירת מלבן על המסךarea
מחשבת את השטח של המלבן
מכאן ניתן לומר שלמחלקה שני סוגי אחריות:
draw
)area
)עכשיו, נתאר מצב שבו שתי אפליקציות עושות שימוש במחלקה Rectangle
:
מבצעת חישובים גיאומטריים.
נעזרת במחלקה Rectangle
כדי לבצע חישוב שטח מלבן (area
).
מדפיסה צורות גיאומטריות.
נעזרת במחלקה Rectangle
לחישוב שטח המלבן (area
) וציורו (draw
)
כמו שבטח הבנתם, המחלקה Rectangle
מפרה את עקרון אחריות יחידה, מה שמביא לתולדה של מספר בעיות:
draw
תצייר את המלבן תיטען, גם אם לא ייעשה בה שימושRectangle
, מה שעשוי להצריך בנייה מחדש של האפליקציה Geometric Calculator.Rectangle
.כדי להימנע מנקודות התורפה שהזכרתי בפסקה למעלה, נבצע הפרדת סוגי אחריות לשתי מחלקות שונות:
GeometricRectangle
<?php
abstract class GeometricRectangle {
abstract public function draw();
}
Rectangle
<?php
abstract class Rectangle {
abstract public function area();
}
על-ידי הפרדת סוגי האחריות לשתי מחלקות שונות, ביטלנו את נקודות התורפה:
GeometricRectangle
לא תטען את המחלקה הגראפית שמציירת את המלבןאבל בינינו, למי מכם יצא לאחרונה לכתוב מחלקה שמטפלת במלבן?
אני לא אוהב פוסטים שנותנים דוגמאות כלליות ופחות-רלוונטיות ומשתדל לא לחטוא לכך בעצמי.
אז בואו נלך על דוגמה שנייה, הפעם עם מחלקה שקיימת כמעט בכל מערכת – מחלקת משתמש:
<?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 דרך כתובת URL.
יש לנו שתי אפשרויות:
printUserInfo
printUserInfoJSON
היכולת לתחזק את הקוד ביעילות ובמהירות נפגעת, והיא צורך הכרחי ותביא אתכם לחיסכון דרמטי של שעות עבודה.
אני לא אומר את זה סתם, כי אם הייתם רואים את מחלקת ה – Admin במערכת הישנה שפיתחתי, הייתם מפסיקים לקרוא את הבלוג הזה תכף ומיד.
כמו בדוגמה הקודמת,גם כאן נפרק את המחלקה לפי סוגי האחריות – 5 מחלקות ייעודיות שיבצעו את הפעולות הנדרשות.
הנה יישום של כל המחלקות מלבד המחלקה שמטפלת בשגיאות דטבייס – כמובן שהיישום לא מהותי והוא לצורך הדוגמה בלבד:
User
המחלקה אחראית לעדכון פרטי המשתמש
<?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
<?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
המחלקה אחראית לבדוק האם למשתמש יש הרשאת גישה לאיזור מסויים.
(תודה לקורא אלכס רסקין שהראה מה הדרך הנכונה לכתוב את המחלקה)
<?php
class UserAccess {
private $greenAreas;
public function __construct($greenAreas) {
$this->greenAreas = $greenAreas
}
public function HasAccessTo($area) {
return in_array($area, $this->greenAreas);
}
}
רוברט מרטין הגדיר אחריות (בהקשר של עקרון אחריות יחידה) כ – "סיבה לשינוי".
הוא גם הזהיר מפני האינסטינקט האנושי שגורם לנו לחשוב על אחריות בקבוצות, למשל: האחריות של מסך היא "להציג תמונה" – אבל תחת האחריות הזו יש הרבה תתי-פריטים, שנפרדים אחד מרעהו לגמרי:
ניתן לטעון שזה לעולם לא נגמר. תמיד אפשר לפרק אחריות אחת לאינספור סוגי אחריות.
וכאן, לדעתי – מגיע החלק החשוב ביותר בעקרון אחריות יחידה:
ניקח לדוגמה את הממשק הבא (נכתב על-ידי רוברט מרטין), שמתאר מודם:
<?php
interface Modem {
public function dial(String $pno);
public function hangUp();
public function send(String $c);
public function receive();
}
באופן טבעי, בהסתכלות ראשונה – הממשק הזה מתאר אחריות אחת – של מודם.
אבל – בהסתכלות מעמיקה יותר כשעקרון אחריות יחידה נמצא בראש – יש לממשק הזה שני תפקידים:
dial
, hangUp
)send
, recieve
)בואו נעצור לרגע ונחשוב – האם כדאי לנו לפרק את הממשק הזה לשני ממשקים שונים?
התשובה תלויה באופן שבו המודם עשוי להשתנות, וזו גם השאלה שעליכם לשאול את עצמכם כדי להחליט האם להפריד מחלקה לפי תפקידים:
האם הסיבה לשינוי תיתכן במציאות?
אם התשובה היא לא חד משמעי – אין סיבה אמיתית ליישם את עקרון אחריות יחידה.
חשוב לי להבהיר את הנקודה של הפסקה הזו: אין כלל אצבע שבאמצעותו ניתן להחליט אם להפריד מחלקה לסוגי אחריות או לא.
החכמה היא לאמץ את צורת המחשבה שעומדת מאחורי העקרון מצד אחד, אבל מצד שני לדעת מתי הוא מתאים ומתי היתרונות שלו לא יכולים לבוא לידי ביטוי.
רוברט מרטין כתב בסיכום עקרון אחריות יחידה: (תרגום חופשי)
עקרון אחריות יחידה הוא אחד מהעקרונות הפשוטים ביותר שקיימים, ואחד מהעקרונות הקשים ביותר ליישם נכון.
איחוד אחריות נעשה על-ידי בני האדם באופן טבעי. מציאת והפרדת אחריות כפולה – זה אחד האתגרים שטמונים בעיצוב תוכנה.
יתר העקרונות מבוססים בצורה כזו או אחרת על עקרון אחריות יחידה.
ולי לא נותר אלא להסכים: בדומה ל – MVC, עקרון אחריות יחידה הוא שינוי תפיסתי בעיקרו.
אבל אחרי שתתחילו ליישם אותו – לא תוכלו ללכת אחורה.
הבטחתי בונוס, שהוא בעצם טיפ:
מצאתי שיישום עקרון אחריות יחידה רלוונטי לא רק במחלקות, אלא גם בפונקציות.
הפרידו אחריות כפולה למס' פונקציות שישמרו את הקוד נקי, קצר, חזק וקל לתחזוקה.
נתראה בפוסט השני בסדרה: עקרון יישום פתוח סגור!
הפוסט עקרון אחריות יחידה הופיע ראשון בMasterScripter