דף הבית > תכנות > מנשקים לפנים

מנשקים לפנים

"מִנְשַק", לפי האקדמיה ללשון, הוא התרגום התקני למונח interface. עם כל ההצדקות שבעולם, אני תמיד אמרתי "ממשק", וכנראה אמשיך ואומר "ממשק" וזה מה יש. התרגום התקני מוצג בפוסט הזה רק פעמיים עד עכשיו, ואני חושב שבזה זה יסתכם 🙂

אז ממשקים.

למה צריך ממשקים? מה הקטע שלהם?

בפוסט הזה אני אסביר את העניין הזה של ממשקים, מי נגד מי וכמה. הפוסט הזה הוא גם סוג של הכנה לנושא של IoC, שכבר התחייבתי לכתוב עליו בעבר, והגיע הזמן לקדם את העניין.

נתחיל מ:

הגדרה לא פורמלית של "מה זה ממשק בדוט נט"

בדוט נט, הקוד שלנו כתוב כמעט תמיד במחלקות (class-ים). פה ושם אנחנו אולי נכתוב enum או struct למיטיבי לכת. פה ושם נכתוב אולי איזה delegate. אבל עיקר הקוד שלנו הוא במחלקות.

וכידוע, למחלקות יכולים להיות: שדות (fields), מאפיינים (properties), מתודות (methods) ואירועים (events). אני מעדיף לא להיכנס כאן לפינות של הקטע הפורמלי, אז סליחה מראש על אי הדיוקים.

ממשק בא לתאר התנהגות של מחלקות. לממשק יכולים להיות מאפיינים, מתודות ואירועים (אגב, אין שדות לממשק). כלומר, ממשק בא לתאר פונקציונאליות מסויימת. בשלב מסויים, נרצה לממש את הפונקציונאליות הזו ע"י מחלקה כלשהי.

אז בשורה התחתונה, הפונקציונאליות (או ההתנהגות) מתוארת ע"י הממשק. המימוש מתקיים במחלקה.

הדוגמה הכי בנאלית, היא לכתוב ממשק כמו IShape, שאמור לתאר התנהגות של צורות גיאומטריות. אפשר, למשל, לחשב לכל הצורות את השטח שלהן. לכן נוכל לכתוב משהו כזה:

public interface IShape
{
  double GetArea();
}

ואז נוכל להצהיר על שתי צורות גיאומטריות. לקחתי שתיים יחסית "קלות" – ריבוע ועיגול:

public class Rectangle : IShape
{
  private readonly double a;
  private readonly double b;

  public Rectangle(double a, double b)
  {
    this.a = a;
    this.b = b;
  }

  public double GetArea()
  {
    return a*b;
  }
}

public class Circle : IShape
{
  private readonly double radius;

  public Circle(double radius)
  {
    this.radius = radius;
  }

  public double GetArea()
  {
    const double pi = Math.PI; // ?
    return pi * radius * radius;
  }
}

כפי שניתן לראות, אלו מחלקות שמממשות את הממשק IShape.

ומה עושים עם זה?

בדוגמה הבנאלית הזו קצת קשה לראות את זה, אבל בעקרון אפשר להגדיר פונקציה שמקבלת פרמטר מסוג IShape. כלומר הפונקציה אדישה ל"איזו צורה" נשלחת אליה כפרמטר, היא רק מעוניינת בהתנהגות שמתוארת ע"י הממשק עצמו:

public static void DoSomething(IShape shape)
{
  Console.WriteLine("the area is {0:00}", shape.GetArea());
}

טוב, דוגמאות בנאליות זה רק כדי לקבל את התחושה. בואו נעבור ל:

דוגמה קונקרטית

(כן, דוגמה אמיתית מהחיים האמיתיים!)

נניח שפיתחנו אתר מכירות עם לקוחות רשומים. הכל טוב ויפה: הלקוחות נרשמים, וגם מבצעים רכישות פה ושם. עכשיו בעלי האתר רוצים לשלוח מייל "תזכורת" ללקוחות שכבר חודש לא קנו כלום. מין צעד שיווקי שכזה.

אז יש לנו כאן שתי משימות:

  1. למצוא את כל הלקוחות האלה
  2. לשלוח להם מייל

(ולמעשה, יש כאן משימה נוספת: לעדכן את הרשומות של הלקוחות האלה כדי לא לשלוח להם מייל נוסף, אבל נתעלם מזה כרגע.) אני מעדיף כרגע להתעלם מהשאלה של "מה הטריגר" להפעלה של הבדיקה הזו, זה פחות משנה כרגע.

כדי לכתוב את המחלקה שמבצעת את זה, נוכל לכתוב קוד שפותח connection באופן ישיר ל DB שלנו, שולף את הרשומות בשאילתה, ושולח מייל.

זה יראה בערך כך:

public class CustomersReminder
{
  public void Remind()
  {
    var connectionString = "…";
    using (var connection = new OleDbConnection(connectionString))
    {
      connection.Open();
      var command = new OleDbCommand("select * from Customers where LastPurchasedAt < ?", connection);
      var todayMinus30 = DateTime.Today.AddDays(30);
      var oleDbParameter = new OleDbParameter {OleDbType = OleDbType.Date, Value = todayMinus30};
      command.Parameters.Add(oleDbParameter);
      using (command)
      using (var reader = command.ExecuteReader(CommandBehavior.CloseConnection))
      {
        while (reader.Read())
        {
          var firstName = (string) reader["FirstName"];
          var lastName = (string) reader["LastName"];
          var email = (string)reader["EmailAddress"];
          SendMailTo(firstName, lastName, email);
        }
      }
    }
  }

  private void SendMailTo(string firstName, string lastName, string email)
  {
    var mailMessage = new MailMessage("do-not-reply@canberra-shopping.com", email);
    mailMessage.Subject = "It's been a while…";
    mailMessage.Body = string.Format("Hi there, {0} {1}, please try our new sales!", firstName, lastName);
    var smtpClient = new SmtpClient();
    try
    {
      smtpClient.Send(mailMessage);
    }
    catch (Exception ex)
    {
      // do something here, like logging or alerting..
    }
  }
}

פתרון שכזה יעבוד, אבל הוא מרכז בתוכו הרבה מאוד תלות ב DB ובאופן שבו שולחים מייל. למעשה, איכות הקוד כאן היא די נמוכה.

במקום הקוד הזה, בואו נניח שיש לנו שתי מחלקות: CustomersFetcher ו EmailSender. נעביר את הקוד הרלוונטי למחלקות האלו. בואו נראה את המחלקה CustomersFetcher:

public class CustomersFetcher
{
  public IEnumerable<CustomerDetails> GetSleepingCustomers()
  {
    List<CustomerDetails> result = new List<CustomerDetails>();
    var connectionString = "…";
    using (var connection = new OleDbConnection(connectionString))
    {
      connection.Open();
      var command = new OleDbCommand("select * from Customers where LastPurchasedAt < ?", connection);
      var todayMinus30 = DateTime.Today.AddDays(30);
      var oleDbParameter = new OleDbParameter { OleDbType = OleDbType.Date, Value = todayMinus30 };
      command.Parameters.Add(oleDbParameter);
      using (command)
      using (var reader = command.ExecuteReader(CommandBehavior.CloseConnection))
      {
        while (reader.Read())
        {
          var customerDetails = new CustomerDetails
                        {
                          FirstName = (string) reader["FirstName"],
                          LastName = (string) reader["LastName"],
                          EmailAddress = (string) reader["EmailAddress"]
                        };
          result.Add(customerDetails);
        }
      }
    }
    return result;
  }
}

public class CustomerDetails
{
  public string FirstName;
  public string LastName;
  public string EmailAddress;
}

המחלקה הזו מבצעת שתי פעולות: שליפת הנתונים מה DB והמרה שלהם ל class דוט נטי "טהור", מה שנקרא גם POCO.

ועכשיו, בואו נחזור אל ה Remind שלנו:

public class CustomersReminder
{
  public void Remind()
  {
    var customersFetcher = new CustomersFetcher();
    var emailSender = new EmailSender();
    foreach (var customer in customersFetcher.GetSleepingCustomers())
    {
      emailSender.SendRemindMessage(customer.FirstName, customer.LastName, customer.EmailAddress);
    }
  }
}

אוקיי, מה שעשינו עד עכשיו: פרקנו את המשימות שלנו למחלקות שונות. זה רעיון די טוב, אבל בואו ניקח אותו צעד אחד קדימה.

האחריות של המתודה Remind היא עדיין לא פשוטה: המתודה הזו מייצרת instance (ובעברית "מופע") של המחלקות CustomersFetcher ו EmailSender. כלומר יש במתודה את שתי השורות הראשונות שבהן יש new. בואו נחסוך את ה new הזה. בואו נקבל את ה instance-ים האלה כפרמטרים:

public class CustomersReminder
{
  public void Remind(CustomersFetcher customersFetcher, EmailSender emailSender)
  {
    foreach (var customer in customersFetcher.GetSleepingCustomers())
    {
      emailSender.SendRemindMessage(customer.FirstName, customer.LastName, customer.EmailAddress);
    }
  }
}

הקוד הזה נראה הרבה יותר טוב. הוא קצר יותר, והוא ברור יותר. כדי לקרוא לקוד שכזה נצטרך לכתוב משהו כזה:

var customersReminder = new CustomersReminder();
customersReminder.Remind(new CustomersFetcher(), new EmailSender());

ועכשיו, עוד פעם, בואו ניקח את הפתרון הזה צעד נוסף קדימה. בואו נתבונן בקוד של המתודה Remind. שימו לב, המתודה Remind מתייחסת רק להתנהגות של CustomersFetcher ושל EmailSender: היא רק משתמשת במתודות שלהן, בלי לדעת מה קורה מאחורי הקלעים. כלומר לא ממש אכפת למתודה Remind איך ה CustomerFetcher מבצע את המתודה שלו. רק חשוב שתהיה מתודה כזו ושהיא תחזיר תוצאות. אותו הדבר גם לגבי ה EmailSender.

אם כך, נוכל ליצור שני ממשקים, ICustomersFetcher ו IEmailSender, שמתארים את ההתנהגות הזו:

public interface ICustomersFetcher
{
  IEnumerable<CustomerDetails> GetSleepingCustomers();
}

public interface IEmailSender
{
  void SendRemindMessage(string firstName, string lastName, string email);
}

והמחלקות שכתבנו צריכות לממש את הממשקים האלה. זאת לא בעיה גדולה – פשוט נוסיף בשורת ההצהרה על ה class גם את הממשקים שהיא אמורה לממש. שאר הקוד – ללא שינוי. למשל:

public class CustomersFetcher : ICustomersFetcher
{}

עכשיו נחזור אל המתודה Remind, ונשנה את החתימה שלה באופן הבא:

public void Remind(ICustomersFetcher customersFetcher, IEmailSender emailSender)
{}

מה קבלנו? -עכשיו המתודה Remind בכלל לא תלויה במימוש, אלא רק בהתנהגות של שליפת הלקוחות ושל שליחת המיילים.

יופי, אבל מה זה נותן?

כאשר אנחנו מתכנתים מול ממשק, אנחנו נהנים ממספר יתרונות:

  1. אנחנו יכולים בכל שלב שהוא לשנות את המימוש, והקוד של המתודה Remind ישאר כפי שהוא. למשל, אם יום אחד נרצה לשלוח מייל דרך איזה WebService ששולח מיילים, אז נכתוב מימוש חדש ונשלח את המימוש הזה כפרמטר למתודה Remind, והקוד של המתודה Remind ימשיך לעבוד כמו שצריך. זה יתרון תחזוקתי, כי אז שינוי במימוש (נוספה מחלקה) לא גורם לשינוי בחתימה של המתודה Remind ולא בקוד שלה. במילים אחרות, ממשק הוא סוג של פרוטוקול של האפליקציה שלנו. כל עוד הפרוטוקול נשאר זהה, נוכל להחליף את המחלקות שמממשות אותו באחרות, ובקלות רבה.
  2. הקטנת הצימוד: המחלקה שלנו תלויה אך ורק בממשקים שהוגדרו. אם היא היתה תלויה במימוש, אז כל שינוי במימוש היה יכול להשפיע על המחלקה שלנו.
  3. אנחנו יכולים למקבל את הפיתוח: מתכנת א' יצור את המימוש של ICustomersFetcher ומתכנת ב' יצור את המימוש של IEmailSender. אף אחד לא מפריע לשני, וזה מקדם את הפרויקט מהר יותר לפרודקשן. למי שעובד בצוות, זה יתרון אדיר.
  4. הקוד שלנו הופך להיות יותר טסטבילי, כלומר יותר מוכן לבדיקות. בהקשר הזה, הקוד של המתודה Remind יכול להיבדק ע"י unit testing באמצעות mocking (עוד על mocking בפוסט עתידי כלשהו).

וכמובן, לא הכל דבש. עבודה עם ממשקים מייצרת עוד קבצים לתחזק, ועלולה להביא ל"הינדוס יתר" (over-engineering).

עוד קצת מוטיבציה

בפוסט הזה, הראיתי תהליך שבו הקוד של המתודה Remind עובר שינויים: מתלות במחלקות לתלות בממשקים. אחרי שמתרגלים לרעיון של הפרדת המימוש מההתנהגות, מתחילים לחשוב ממשקים. כלומר אפשר להתחיל לקודד ע"י כתיבת הממשק, בלי לכתוב אף מימוש, וזה אפילו מתקמפל. זה משאיר אותנו ממוקדים בקוד הנוכחי, ומאפשר לנו לכתוב קוד כאילו יש לנו כבר איזשהו מימוש.

בואו נראה דוגמה שממחישה את הנקודה האחרונה.

נניח שאנחנו רוצים לכתוב מתודה שמקבלת URL של תמונה, והיא צריכה:

  1. להוריד את התמונה מהאינטרנט
  2. לשנות את הגודל של התמונה כך שהרוחב והגובה לא יעלו על 100 פיקסלים (ולשמור על פרופורציית גובה-רוחב)
  3. לשמור את התמונה החדשה בפורמט PNG בתיקיה מסויימת

עכשיו, בואו נכתוב את המתודה בחשיבה שהיא "מונחית ממשקים". נוכל, אם כן, לכתוב את הקוד הבא:

public class ImageProcessing
{
  public void ProcessImage(Uri imageUrl, IDownloader downloader, IImageResizer imageResizer, IFileSystem fileSystem)
  {
    byte[] imageBytes = downloader.Get(imageUrl);
    Image bitmap = Image.FromStream(new MemoryStream(imageBytes));
    Image afterResize = imageResizer.ResizeToMax(bitmap, 100);
    fileSystem.StoreImage(afterResize);
  }
}

public interface IFileSystem
{
  void StoreImage(Image image);
}

public interface IImageResizer
{
  Image ResizeToMax(Image bitmap, int maxWidthOrHeight);
}

public interface IDownloader
{
  byte[] Get(Uri imageUrl);
}

מבינים את הרעיון? אני כותב קוד בעזרת ממשקים. אני כותב קודם את הממשקים. כלומר, אני רואה כבר משלב ראשון מהם ה"שירותים" שאני אצטרך. כל שירות שכזה הוא ממשק. המימוש של הממשק כרגע לא מעניין אותי. מה שמעניין אותי זה לכתוב קוד שמתקמפל, ואח"כ אני אתחיל לכתוב את המימושים.

למה זה טוב?

כי זה משאיר אותי מרוכז בפתרון של הקוד. הכתיבה לא "מסתעפת" לפרטים של "איך להוריד קובץ מהאינטרנט" ו"איך לשנות גודל של תמונה". למעשה, אני מציב לעצמי אבני דרך בקוד שלי. כל מימוש של ממשק מקדם אותי אל הפתרון המלא. כשאני כותב את המימוש לכל ממשק, אני יודע שזה משהו שאני באמת צריך אותו. כך אני נמנע מלכתוב קוד שאני לא צריך אותו, והתפוקה שלי משתפרת. בקיצור, אני נשאר ממוקד ואני לא "מתפזר" לכיוונים שהם לא רלוונטיים. אלה יתרונות משמעותיים בפיתוח תוכנה.

למה לא לכתוב פונקציות?

כי פונקציות זה כל כך תכנות פרוצדורלי :-).
טוב, ברצינות: עקרון חשוב מאוד בהנדסת תוכנה הוא SRP, ראשי תיבות של Single Responsibility Principle, שזה אומר שכל מחלקה אחראית על תחום אחד בלבד. אני אחזור על זה: מחלקה אמורה להיות אחראית על תחום אחד בלבד. כתיבה של פונקציות באותה מחלקה גורמת למחלקה להיות אחראית על יותר מתחום אחד: גם הורדה של קבצים, גם עיבוד תמונה וגם התעסקות עם מערכת הקבצים. זה אומר שמספיק שמשהו אחד משתנה (נניח, האופן שבו מבצעים עיבוד תמונה) כדי שכל המחלקה הזו תשתנה. לדעתי האישית, אגב, מרגע שכותבים קוד לפי SRP מתחילים להבין את המשמעות של OOP ושל הנדסת תוכנה.

עדיין לא השתכנעתם?

אלה מכם שכבר שמעו על ממשקים וקראו מאמרים והבינו את העקרון, אבל עדיין מסתובבים עם תחושת אי נוחות מסויימת בעניין הזה – הפסקה הזו מיועדת לכם. יש עוד עניין שלא נכנסתי אליו, וזה הקטע הכי "פישי" ובכוונה השארתי אותו לסוף: מי מחליט איזה מימוש בפועל יגיע אל המתודה Remind? הרי אם יש בקוד שלנו קריאה למתודה Remind, כבר אז צריכה להתקבל החלטה איזה מימוש שולחים כפרמטרים למתודה. אז על מי אנחנו עובדים?

נכון, באופן שבו כתוב הקוד כרגע, מי שקורא למתודה Remind, אמור לדעת איזה מימושים להעביר. אבל זו לא הדרך היחידה לכתוב את הקוד הזה. כאמור, הפוסט הזה הוא סוג של הכנה ל IoC, ולכן ההסברים יחכו לפוסט אודות IoC, ויתן פתרון גם לעניין האחרון שהעליתי כאן.

וסיומת בפרפאזה על הסיומת הקבועה של משה קפלן: ממשקים לפתח!

אפשר להוריד את הקוד מכאן.

קטגוריות:תכנות תגיות:, , ,
  1. ניר
    8 פברואר, 2011 מתוך 20:32 | #1

    כמו שאמר לי פעם מנהל הפיתוח שלי – כאן אנחנו כבר ממש נכנסים (לפחות להתחלה) ל OOP. הכוח של OOP להציע גמישות, טסטביליות וצימוד חלש ככל הניתן, כך שהקוד שלנו יהיה יותר פתוח לשינויים ושימוש מחדש.

    מחכה להמשך!

  2. Yoav
    19 אוקטובר, 2013 מתוך 10:30 | #2

    זהב!!! מצטער שלא נתקלתי בפוסט הזה לפני שנה…

  1. אין הפניות עדיין.

Quantcast