Здравей отново! Нека започна с няколко прости въпроса — трябва ли да купувате нов телефон всеки път, когато искате да изтеглите и изпробвате ново приложение? Или трябва да отидете да купите нова антена за вашия телевизор, когато искате да промените абонирания пакет канали? Или трябва да прекабелите домашното си електрическо захранване, когато купувате нов уред? Не, защо? Защото въпреки че всеки нов уред може да има различно напрежение и ампера, самият уред идва с електрически адаптер, отговорен за свързването на уреда със стандартното електрическо захранване във вашия дом. С други думи, промените, настъпващи в изискванията за захранване на домакинските уреди, са капсулирани от стандартното захранване във вашия дом, като по този начин се гарантира, че

what stays the same is isolated from what changes often.

Нека първо разгледаме един прост пример за проектиране на софтуер. Да кажем, че имаме екземпляр на клас Animal. Животното може да има рутина за движение, която може да варира в зависимост от вида на животното, нещо като:

if (animal.type == dog)
    print("animal is walking");
else if (animal.type == bird)
    print("animal is flying");
else if (animal.type == snake)
    print("animal is crawling");

Сега, когато внедряваме рутината преместване по този начин, навсякъде в нашия проект, където имаме нужда животното да се движи, трябва да напишем това if-else или да кажем блок с превключване на случай. Ако утре искаме да добавим ново животно тип кенгуру, което отпечатва „животното подскача“, може да се наложи да добавим нов случай към всички места в нашето приложение, където използваме рутинната процедура за движение.

OOPs на помощ?

Сигурен съм, че вече сте създали в главата си класа Животно с рутина move() и неговите подкласове Куче, Змия, Птица, Кенгуру, всички със собствена отменена версия на метод move() и готово! И ще бъдете абсолютно прави, като мислите за капсулирането по този начин (въпреки че за да бъдем честни, тук има и капсулиране, и абстракция, но това е история за друг път), но нека да направим крачка назад. Капсулирането не е просто принцип за обектно ориентирано програмиране, то е по-скоро общ принцип за проектиране на софтуер, който съществува дори във функционалното програмиране.

Как да капсулираме променящи се части от нашия код при липса на обекти и наследяване? Достатъчно просто, ако се върнем назад, за да попитаме каква е била основната цел — Капсулиране на това, което се променя от това, което не. Така че, ако имаме десет места в нашия код, където имаме блока за превключване на случаите за рутинното движение на животни, можем да го преместим в метод, както по-долу:

void move(animal) {
    if (animal.type == dog)
        print("animal is walking");
    else if (animal.type == bird)
        print("animal is flying");
    else if (animal.type == snake)
        print("animal is crawling");
}

Така че сега, когато добавяме нов тип животно, просто трябва да добавим ред код на едно място в нашия проект. Колкото и тривиално да звучи - това също е капсулиране! Това, което исках да подчертая тук, е, че капсулирането, макар и очевидно много по-мощно в обектно-ориентирана среда, съществува дори извън ООП и е еднакво полезно и значимо, ако разберем основните му цели и предимства.

Капсулирането е навсякъде!

Когато завъртите волана на колата си и колелата на колата се завъртят — има множество движещи се части, които работят заедно, за да трансформират действието ви в крайния ефект върху колелата на колата, но всички тези подробности са капсулирани от вас. Не се бъркайте, ако някой каже, че това е пример за абстракция – той също е прав! И аз съм така, когато казвам, че този и повечето други случаи, които можем да намерим, както в света на софтуера, така и в реалния свят, са примери за капсулиране и абстракция в действие заедно. Тези две концепции са различни, но свързани и най-често се установява, че функционират заедно (и да, ще обсъдим всичко това подробно в по-късна част).

По подобен начин всяка система (софтуер или не), където вътрешните детайли са скрити от крайния потребител и потребителят може да взаимодейства само чрез добре дефиниран и (често) силно абстрахиран интерфейс, е пример за капсулиране в действие. Всички устройства с активиран USB, които използваме, са базирани на капсулиране, тъй като всички те взаимодействат с USB устройствата чрез глобален, добре дефиниран интерфейс и след като USB портът и USB устройството са свързани, това, което се случва от двете страни на тази връзка, е без значение за другата страна.

Капсулация в софтуерния дизайн

Капсулирането в софтуерния дизайн обикновено се използва за две цели - скриване на вътрешни подробности (като лични данни, вътрешно внедряване и т.н.) и изолиране на несвързани проблеми един от друг. Първият е доста очевидно видим и много ясно желателен, тъй като винаги искате да имате частни данни вътре в клас (или система), които не са изложени на неговите клиенти, и също така искате да имате скрити реални реализации, така че взаимодействието между клиент а услугата е чрез интерфейси и не зависи от изпълнението. Сред многото предимства на това е фактът, че вашето внедряване може да се промени, без да притеснява клиента. Също така, много методи за информационна сигурност се основават на скриването на данни като функция.

Последната цел за отделяне на несвързаните проблеми (наричана още разединяване) е огромна, когато става въпрос за проектиране на по-добри софтуерни системи. Връщайки се към заглавието на това парче - капсулирането е супер мощно, когато става въпрос за брониране на вашите системи и дизайни от предвидими промени. Промяната е неизбежна, така че няма смисъл да избягваме нейната възможност или въздействие върху съществуващите системи и най-доброто, което можем да направим, е да гарантираме, че проектираме нашите системи, като имаме предвид бъдещите промени. Разбира се, човек може да предвиди толкова много, но вашата цел трябва да бъде да изолирате аспекти, които биха могли да претърпят промени по-често от други, които може да останат същите, така че отделните модули на вашия проект да могат да се развиват независимо. Това има много свързани ползи, едно от тях е по-малки разходи при извършване на промени — по-малко количество код за промяна, по-малко тестване и повече увереност в зоната на въздействие на тези промени. След това е фактът, че можете да разпределите работата си по проекта между членовете на екипа ефективно, с по-малко прекъсване на работата на един член от тази на друг, тъй като отделните части могат да бъдат разработени отделно.

Пример за проектиране

Нека да разгледаме типичен пример за дизайн на софтуер, използващ капсулиране.

Да кажем, че изграждаме приложение за съобщения като WhatsApp и първата ни цел е да поддържаме текстови съобщения между две клиентски устройства. Първият ни дизайн за обекта Message може да има само едно поле „текст“ или „низ“:

class Message {
    String text;
}

Въз основа на това „предположение“, че в съобщението има само текстово поле (и нищо друго), можем да напишем кода за генериране на съобщението и кода за обработка на съобщението:

class MessageDisplay {
    public void displayMessage(Message message) {
        System.out.println(message.text);
    }
}

Такива рутинни процедури ще доведат до много проблеми, когато се опитваме да въведем други типове съобщения, като например аудио, видео или фото съобщения (както правят повечето приложения за съобщения). Можете ли да видите как капсулирането може да ни помогне тук?

На първо място, обектът Message трябва да капсулира (и скрие) това, което е вътре в него, и по този начин рутинните програми за изпращане и получаване на съобщения не трябва да знаят (или да приемат) вътрешния състав на съобщението, така че да не вземат решения въз основа на това. Можете да спорите, че някой някъде в кода ще трябва да знае какво има вътре в съобщението и това е напълно наред. Частта от код, която е отговорна за показването на всяко съобщение в потребителския интерфейс, ще трябва да знае как да показва съдържанието на съобщението и ще се нуждае от знания как да борави с различни видове съдържание. Но все още можем да скрием вътрешността на съобщение от други части на кода, които не се нуждаят от това знание. Един такъв модул може да е този, който отговаря за изпращането на съобщението.

Например, нека приемем, че тези съобщения се изпращат по мрежата и ние използваме някакъв вид кодиране-декодиране за същото, рутината за кодиране трябва да бъде внимателно избрана, така че да може да обработва, да кажем, URL адреси и други специални знаци (може би емотикони ), а не само обикновен текст. Ако не правим никакви предположения относно вътрешния състав на обект на съобщение, нашият код за изпращане и получаване на съобщения ще бъде възможно най-генеричен.

Второ, трябва да предвидим, че ще добавим нови типове съобщения в бъдеще и следователно да запазим обхвата на такива промени в нашите дизайнерски решения. Много тривиален начин да направите това е да имате някои допълнителни полета в обекта на съобщението, които могат да поддържат бъдещи промени:

enum MessageType {
    TEXT,
    PHOTO,
    VIDEO;
}
class Message {
    MessageType type;
    String messageData;    //text, or URL
}

В горния пример нашият обект на съобщение има поле за тип, което в момента има само една възможна стойност „ТЕКСТ“, но по-късно може да има нови типове като „СНИМКА“ или „ВИДЕО“ като тип на съобщение. Сега, в допълнение към полето тип, може да се нуждаем от поле за данни, което ще има различно значение в случай на различни типове съобщения - в случай на текстови съобщения, тези данни може да са обикновен текст, в случай на снимка, това може да бъде URL на снимката. С развитието на софтуера може да се нуждаем от повече данни за тип съобщение, вместо само едно поле String — може би искаме да изпратим начален и краен час, когато споделяме URL на видеоклип в YouTube като съобщение. В такива случаи ще има смисъл отново да капсулиратетези данни и може би да ги наречете нов тип обект — MessageData или нещо подобно:

class Message {
    Long messageId;
    MessageType messageType;
    String senderId;
    String receivedId;
    MessageData messageData;    //text, or URL
}
abstract class MessageData {
}
class TextMessageData extends MessageData {
    String textMessage;
}
class PhotoMessageData extends MessageData {
    String imageDownloadUrl;
    Integer width;
    Integer height;
    String imageCaption;
    //add more params as needed
}
class VideoMessageData extends MessageData {
    String videoDownloadUrl;
    Long videoStartOffsetMillis;
    //more such params
}

По този начин вашият обект на съобщение може да има някои полета, общи за всички типове съобщения — като messageID, messageType, senderID, receiverID и т.н. и данните, които варират в зависимост от типа на съобщението, ще бъдат капсулирани в обект MessageData (или интерфейс), който може по-късно да се разшири във PhotoMessageData или VideoMessageData и т.н. За любопитните души това е Стратегическият модел.

Освен това добавянето на нов тип съобщение трябва да изисква минимални промени, може би само в рутинните процедури за създаване и показване на съобщения. Има много модели на проектиране като Factory Pattern, които са специализирани в капсулирането на създаването на обекти, за да се гарантира, че добавянето на нов тип обект няма да наруши други части от кодовата база. Най-голямото предимство на това не са по-малкото редове промени в кода, а по-малко прекъсване и въздействие върху съществуващи части (особено тези, които са изолирани от тези промени поради нашите отлични техники за капсулиране) и следователно по-висока увереност в стабилността и по-малко разходи за тестване, когато добавяне на нов тип съобщение.

Щипка сол

В един истински екип за разработка на софтуер винаги ще има безпрецедентни изисквания към функциите, идващи от продуктовия екип - функции, които могат да нарушат някои фундаментални инварианти или предположения, които разработчикът първоначално е направил, или функции, които се нуждаят от промени в част от кода, която Разработчикът беше сигурен, че никога няма да има нужда от промяна или дори промени във функции, които засягат множество модули на софтуера. Често няма да можете да се предпазите и планирате срещу такива разрушителни промени, защото такъв вид предвиждане не съществува в реалния свят. И за да бъдем честни, ако започнем да приемаме, че всяка част от нашия софтуер може да се развива независимо и следователно започнем да капсулираме (или изолираме) дори свързани проблеми един от друг, тогава предимствата на интелигентното капсулиране се заменят с проблемите на прекомерната употреба на капсулиране.

Както всичко останало в реалния живот, това също е проблем за вземане на решения и трябва да намерите правилния баланс. С интелигентното използване на капсулирането (и неговия братовчед Abstraction), много проблеми с дизайна и препятствия могат да бъдат избегнати чрез предварително планиране, а някои могат да бъдат решени лесно, дори и да не бъдат напълно избегнати.

PS: Ако сте харесали статията, моля, подкрепете я с ръкопляскания👏 по-долу. Наздраве!