Защо се простира е зло

В extendsключовата дума е зло; може би не на ниво Чарлз Менсън, но достатъчно лошо, че трябва да се избягва, когато е възможно. Книгата „Банда от четири дизайнерски модела “ обсъжда подробно замяната на наследяването на изпълнението ( extends) с наследяване на интерфейса ( implements).

Добрите дизайнери пишат по-голямата част от своя код по отношение на интерфейси, а не на конкретни базови класове. Тази статия описва защо дизайнерите имат толкова странни навици и също така представя няколко основи на програмиране, основани на интерфейса.

Интерфейси спрямо класове

Веднъж присъствах на среща на потребителска група на Java, където Джеймс Гослинг (изобретателят на Java) беше представеният говорител. По време на запомнящата се сесия с въпроси и отговори, някой го попита: "Ако можете да направите Java отново, какво бихте променили?" „Бих изоставил часовете“, отговори той. След като смехът стихна, той обясни, че истинският проблем не са класовете сами по себе си, а по-скоро наследяването на изпълнението ( extendsвръзката). Наследяването на интерфейса ( implementsвръзката) е за предпочитане. Трябва да избягвате наследяване на изпълнение, когато е възможно.

Загуба на гъвкавост

Защо трябва да избягвате наследяването на изпълнението? Първият проблем е, че изричното използване на конкретни имена на класове ви заключва в конкретни изпълнения, което прави ненужните промени ненужно трудни.

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

Вместо да внедрите функции, от които може да се нуждаете, вие внедрявате само функциите, които определено ви трябват, но по начин, който приспособява промените. Ако нямате тази гъвкавост, паралелното развитие просто не е възможно.

Програмирането към интерфейси е в основата на гъвкавата структура. За да разберем защо, нека да разгледаме какво се случва, когато не ги използвате. Обмислете следния код:

f () {Списък на LinkedList = нов LinkedList (); // ... g (списък); } g (LinkedList list) {list.add (...); g2 (списък)}

Сега да предположим, че се появи ново изискване за бързо търсене, така че това LinkedListне работи. Трябва да го замените с a HashSet. В съществуващия код тази промяна не е локализирана, тъй като трябва да модифицирате не само, f()но и g()(което взема LinkedListаргумент) и всичко g()предава списъка на.

Пренаписване на кода по следния начин:

f () {Списък с колекции = нов LinkedList (); // ... g (списък); } g (Списък с колекции) {list.add (...); g2 (списък)}

дава възможност да се промени свързаният списък в хеш таблица, просто като се замени new LinkedList()с new HashSet(). Това е. Не са необходими други промени.

Като друг пример, сравнете този код:

f () {Колекция c = нов HashSet (); // ... g (c); } g (Колекция c) {за (Iterator i = c.iterator (); i.hasNext ();) do_something_with (i.next ()); }

до това:

f2 () {Колекция c = нов HashSet (); // ... g2 (c.iterator ()); } g2 (итератор i) {while (i.hasNext ();) do_something_with (i.next ()); }

В g2()метода вече могат да преминават Collectionдеривати, както и списъците с ключ и стойност, можете да получите от Map. Всъщност можете да пишете итератори, които генерират данни, вместо да обхождат колекция. Можете да напишете итератори, които подават в програмата информация от тестово скеле или файл. Тук има огромна гъвкавост.

Куплиране

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

Като дизайнер трябва да се стремите да сведете до минимум връзките на свързване. Не можете да премахнете изцяло свързването, защото извикването на метод от обект от един клас към обект от друг е форма на свободно свързване. Не можете да имате програма без някакво свързване. Независимо от това, можете значително да минимизирате свързването, като робско следвате OO (обектно-ориентирани) предписания (най-важното е, че изпълнението на обект трябва да бъде напълно скрито от обектите, които го използват). Например променливите на екземпляра на обекта (полета на членове, които не са константи), винаги трябва да бъдат private. Период. Без изключения. Някога. Искам да кажа. (Понякога можете да използвате protectedметоди ефективно, ноprotected променливите на екземпляра са мерзост.) Никога не бива да използвате get / set функции по същата причина - те са просто прекалено сложни начини да направите полето публично (въпреки че функциите за достъп, които връщат пълноценни обекти, а не стойност от основен тип, са разумно в ситуации, когато класът на върнатия обект е ключова абстракция в дизайна).

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

Крехкият проблем на базовия клас

Сега, нека приложим концепцията за свързване към наследяването. В система за наследяване на изпълнение, която използва extends, получените класове са много тясно свързани с основните класове и тази тясна връзка е нежелана. Дизайнерите са приложили прозвището "крехкият проблем на базовия клас", за да опишат това поведение. Основните класове се считат за крехки, тъй като можете да модифицирате основен клас по привидно безопасен начин, но това ново поведение, когато е наследено от производни класове, може да доведе до неизправност на производни класове. Не можете да разберете дали промяната на базовия клас е безопасна, като просто изследвате методите на базовия клас изолирано; трябва да разгледате (и да тествате) и всички производни класове. Освен това трябва да проверите целия код, който използва както базовия клас, така иобекти от производен клас също, тъй като този код може също да бъде нарушен от новото поведение. Една проста промяна на основен клас може да направи цялата програма неработоспособна.

Нека да разгледаме крехките проблеми на свързването на базовия клас и базовия клас заедно. Следният клас разширява класа на Java, ArrayListза да се държи като стек:

клас Stack разширява ArrayList {private int stack_pointer = 0; публично пусто пускане (статия на обекта) {add (stack_pointer ++, article); } публичен обект pop () {return remove (--stack_pointer); } public void push_many (Object [] статии) {for (int i = 0; i <articles.length; ++ i) push (статии [i]); }}

Дори клас, толкова прост като този, има проблеми. Помислете за това, което се случва, когато даден потребител се възползва наследство и използва ArrayListе clear()метод, за да се появи всичко от стека:

Стек a_stack = нов стек (); a_stack.push ("1"); a_stack.push ("2"); a_stack.clear ();

Кодът се компилира успешно, но тъй като базовият клас не знае нищо за указателя на стека, Stackобектът е в недефинирано състояние. Следващото извикване за push()поставяне на новия елемент в индекс 2 ( stack_pointerтекущата стойност на), така че стекът има три елемента - долните два са боклук. ( StackКласът на Java има точно този проблем; не го използвайте.)

Едно решение на нежелания проблем с наследяването на метод е Stackда се заменят всички ArrayListметоди, които могат да променят състоянието на масива, така че замените или да манипулират правилно указателя на стека, или да извадят изключение. ( removeRange()Методът е добър кандидат за хвърляне на изключение.)

Този подход има два недостатъка. Първо, ако замените всичко, базовият клас наистина трябва да бъде интерфейс, а не клас. Няма смисъл от наследяване на изпълнение, ако не използвате нито един от наследените методи. Второ и по-важното е, че не искате стека да поддържа всички ArrayListметоди. Този досаден removeRange()метод не е полезен например. Единственият разумен начин за внедряване на безполезен метод е той да изхвърли изключение, тъй като никога не бива да се извиква. Този подход ефективно премества това, което би представлявало грешка по време на компилация, в изпълнение. Не е добре. Ако методът просто не е деклариран, компилаторът извежда грешка, която не е намерена. Ако методът е там, но хвърля изключение, няма да разберете за повикването, докато програмата действително не стартира.

По-добро решение на проблема с базовия клас е капсулирането на структурата на данните, вместо да се използва наследяване. Ето нова и подобрена версия на Stack:

клас стек {private int stack_pointer = 0; private ArrayList the_data = new ArrayList (); публично пусто пуш (статия на обекта) {the_data.add (stack_pointer ++, article); } публичен обект pop () {върнете the_data.remove (--stack_pointer); } public void push_many (Object [] статии) {for (int i = 0; i <o.length; ++ i) push (статии [i]); }}

Засега добре, но помислете за крехкия проблем с базовия клас. Да предположим, че искате да създадете вариант, Stackкойто проследява максималния размер на стека за определен период от време. Едно възможно изпълнение може да изглежда така:

клас Monitorable_stack разширява Stack {private int high_water_mark = 0; private int current_size; публично пусто пускане (статия на обекта) {if (++ current_size> high_water_mark) high_water_mark = current_size; super.push (статия); } публичен обект pop () {--current_size; връщане super.pop (); } public int maximum_size_so_far () {return high_water_mark; }}

Този нов клас работи добре, поне за известно време. За съжаление, кодът използва факта, че push_many()върши своята работа чрез обаждане push(). Отначало тази подробност не изглежда като лош избор. Той опростява кода и получавате производната версия на класа на push(), дори когато Monitorable_stackдостъпът е достъпен чрез Stackпрепратка, така че high_water_markактуализациите са правилни.

One fine day, someone might run a profiler and notice the Stack isn't as fast as it could be and is heavily used. You can rewrite the Stack so it doesn't use an ArrayList and consequently improve the Stack's performance. Here's the new lean-and-mean version:

class Stack { private int stack_pointer = -1; private Object[] stack = new Object[1000]; public void push( Object article ) { assert stack_pointer = 0; return stack[ stack_pointer-- ]; } public void push_many( Object[] articles ) { assert (stack_pointer + articles.length) < stack.length; System.arraycopy(articles, 0, stack, stack_pointer+1, articles.length); stack_pointer += articles.length; } } 

Notice that push_many() no longer calls push() multiple times—it does a block transfer. The new version of Stack works fine; in fact, it's better than the previous version. Unfortunately, the Monitorable_stack derived class doesn't work any more, since it won't correctly track stack usage if push_many() is called (the derived-class version of push() is no longer called by the inherited push_many() method, so push_many() no longer updates the high_water_mark). Stack is a fragile base class. As it turns out, it's virtually impossible to eliminate these types of problems simply by being careful.

Обърнете внимание, че нямате този проблем, ако използвате наследяване на интерфейса, тъй като няма наследена функционалност, която да ви се повреди. Ако Stackе интерфейс, изпълнен както от Simple_stacka Monitorable_stack, така и от a , тогава кодът е много по-здрав.