1. Общ преглед
Претоварването и заменянето на методите са ключови концепции на програмния език Java и като такива те заслужават задълбочен поглед.
В тази статия ще научим основите на тези концепции и ще видим в какви ситуации те могат да бъдат полезни.
2. Метод Претоварване
Претоварването на метода е мощен механизъм, който ни позволява да дефинираме API на кохезивен клас. За да разберем по-добре защо претоварването на метода е толкова ценна характеристика, нека видим прост пример.
Да предположим, че сме написали наивен полезен клас, който реализира различни методи за умножаване на две числа, три числа и т.н.
Ако сме дали методите заблуждаващи или двусмислени имена, като multiply2 () , multiply3 () , multiply4 (), тогава това би било лошо проектиран API на класа. Тук се появява претоварването на метода.
Най-просто казано, можем да реализираме претоварване на метода по два различни начина:
- внедряване на два или повече метода, които имат едно и също име, но вземат различен брой аргументи
- внедряване на два или повече метода, които имат едно и също име, но вземат аргументи от различен тип
2.1. Различен брой аргументи
Класът множител накратко показва как да претоварите метода multiply () , като просто дефинирате две реализации, които вземат различен брой аргументи:
public class Multiplier { public int multiply(int a, int b) { return a * b; } public int multiply(int a, int b, int c) { return a * b * c; } }
2.2. Аргументи от различен тип
По същия начин можем да претоварим метода multiply () , като го накараме да приема аргументи от различни типове:
public class Multiplier { public int multiply(int a, int b) { return a * b; } public double multiply(double a, double b) { return a * b; } }
Освен това е легитимно да се дефинира класът Множител и с двата вида претоварване на метода:
public class Multiplier { public int multiply(int a, int b) { return a * b; } public int multiply(int a, int b, int c) { return a * b * c; } public double multiply(double a, double b) { return a * b; } }
Струва си да се отбележи обаче, че не е възможно да има две реализации на метода, които да се различават само по своите типове на връщане .
За да разберем защо - нека разгледаме следния пример:
public int multiply(int a, int b) { return a * b; } public double multiply(int a, int b) { return a * b; }
В този случай кодът просто няма да се компилира поради неяснотата на извикването на метода - компилаторът няма да знае коя реализация на multiply () да извика.
2.3. Тип Промоция
Една изискана функция, предоставена от претоварването на метода, е така наречената промоция на типа, известна още като разширяване на примитивното преобразуване .
С прости думи, един даден тип имплицитно се повишава до друг, когато няма съвпадение между типовете аргументи, предадени на претоварения метод и конкретно изпълнение на метод.
За да разберете по-ясно как работи промоцията на типа, помислете за следните реализации на метода multiply () :
public double multiply(int a, long b) { return a * b; } public int multiply(int a, int b, int c) { return a * b * c; }
Сега извикването на метода с два int аргумента ще доведе до повишаване на втория аргумент до long , тъй като в този случай няма съвпадаща реализация на метода с два int аргумента.
Нека видим бърз единичен тест, за да демонстрираме промоция на типа:
@Test public void whenCalledMultiplyAndNoMatching_thenTypePromotion() { assertThat(multiplier.multiply(10, 10)).isEqualTo(100.0); }
И обратно, ако извикаме метода със съответстваща реализация, промоцията на типа просто не се извършва:
@Test public void whenCalledMultiplyAndMatching_thenNoTypePromotion() { assertThat(multiplier.multiply(10, 10, 10)).isEqualTo(1000); }
Ето обобщение на правилата за популяризиране на типа, които се прилагат за претоварване на метода:
- байт може да бъде повишен до къс, int, long, float или double
- кратко може да се повиши до int, long, float или double
- char може да бъде повишен до int, long, float или double
- int може да се повиши до long, float или double
- long могат да бъдат повишени до плаващи или двойни
- float може да се повиши до двойно
2.4. Статично обвързване
Способността да се свързва конкретно извикване на метод към тялото на метода е известна като обвързване.
В случай на претоварване на метода, обвързването се извършва статично по време на компилация, поради което се нарича статично обвързване.
Компилаторът може ефективно да зададе обвързването по време на компилация, като просто проверява подписите на методите.
3. Замяна на метода
Заместването на метода ни позволява да предоставим фино изпълнени реализации в подкласове за методи, дефинирани в основен клас.
Докато заместването на метода е мощна характеристика - като се има предвид, че това е логична последица от използването на наследство, един от най-големите стълбове на ООП - кога и къде да се използва трябва да се анализира внимателно, за всеки отделен случай .
Нека да видим сега как да използваме замяна на метода чрез създаване на проста връзка, базирана на наследство (“is-a”).
Ето базовия клас:
public class Vehicle { public String accelerate(long mph) { return "The vehicle accelerates at : " + mph + " MPH."; } public String stop() { return "The vehicle has stopped."; } public String run() { return "The vehicle is running."; } }
И ето един измислен подклас:
public class Car extends Vehicle { @Override public String accelerate(long mph) { return "The car accelerates at : " + mph + " MPH."; } }
В йерархията по-горе просто сме заменили метода accelerate () , за да осигурим по-усъвършенствана реализация за подтипа Car.
Тук е ясно да се види, че ако дадено приложение използва екземпляри от класа Vehicle , то може да работи и с екземпляри на Car, тъй като и двете реализации на метода accelerate () имат един и същ подпис и един и същ тип връщане.
Нека напишем няколко единични теста за проверка на класовете превозни средства и автомобили :
@Test public void whenCalledAccelerate_thenOneAssertion() { assertThat(vehicle.accelerate(100)) .isEqualTo("The vehicle accelerates at : 100 MPH."); } @Test public void whenCalledRun_thenOneAssertion() { assertThat(vehicle.run()) .isEqualTo("The vehicle is running."); } @Test public void whenCalledStop_thenOneAssertion() { assertThat(vehicle.stop()) .isEqualTo("The vehicle has stopped."); } @Test public void whenCalledAccelerate_thenOneAssertion() { assertThat(car.accelerate(80)) .isEqualTo("The car accelerates at : 80 MPH."); } @Test public void whenCalledRun_thenOneAssertion() { assertThat(car.run()) .isEqualTo("The vehicle is running."); } @Test public void whenCalledStop_thenOneAssertion() { assertThat(car.stop()) .isEqualTo("The vehicle has stopped."); }
Сега нека видим някои единични тестове, които показват как методите run () и stop () , които не са заменени, връщат равни стойности както за Car, така и за Vehicle :
@Test public void givenVehicleCarInstances_whenCalledRun_thenEqual() { assertThat(vehicle.run()).isEqualTo(car.run()); } @Test public void givenVehicleCarInstances_whenCalledStop_thenEqual() { assertThat(vehicle.stop()).isEqualTo(car.stop()); }
В нашия случай имаме достъп до изходния код и за двата класа, така че можем ясно да видим, че извикването на метода accelerate () на базовия екземпляр на Vehicle и извикването на accelerate () на Car instance ще върне различни стойности за един и същ аргумент.
Следователно, следващият тест показва, че замененият метод се извиква за екземпляр на Car :
@Test public void whenCalledAccelerateWithSameArgument_thenNotEqual() { assertThat(vehicle.accelerate(100)) .isNotEqualTo(car.accelerate(100)); }
3.1. Заменяемост на типа
Основен принцип в ООП е този на заменяемостта на типа, който е тясно свързан с Принципа на заместване на Лисков (LSP).
Най-просто казано, LSP заявява, че ако дадено приложение работи с даден основен тип, то то също трябва да работи с всеки от неговите подтипове . По този начин заменяемостта на типа се запазва правилно.
Най-големият проблем с отменянето на метода е, че някои специфични реализации на метода в производни класове може да не се придържат напълно към LSP и следователно да не успеят да запазят заместимостта на типа.
Разбира се, валидно е да се направи заменен метод, който да приема аргументи от различни типове и да връща и друг тип, но с пълно спазване на тези правила:
- Ако методът в базовия клас приема аргумент (и) от даден тип, замененият метод трябва да приема същия тип или супертип (известен още като аргументи на контравариантния метод)
- Ако метод в базовия клас връща void , замененият метод трябва да върне void
- Ако метод в базовия клас връща примитив, замененият метод трябва да върне същия примитив
- Ако методът в основния клас връща определен тип, замененият метод трябва да върне същия тип или подтип (известен също като ковариантния тип на връщане)
- Ако метод в базовия клас изхвърля изключение, замененият метод трябва да изхвърли същото изключение или подтип на изключението на базовия клас
3.2. Динамично обвързване
Като се има предвид, че заместването на метода може да се реализира само с наследяване, където има йерархия на основен тип и подтип (и), компилаторът не може да определи по време на компилиране какъв метод да извика, тъй като както базовият клас, така и подкласовете определят същите методи.
В резултат на това компилаторът трябва да провери вида на обекта, за да знае кой метод трябва да бъде извикан.
Тъй като тази проверка се случва по време на изпълнение, заместването на метода е типичен пример за динамично обвързване.
4. Заключение
В този урок научихме как да реализираме претоварване на метода и заместване на метода и разгледахме някои типични ситуации, в които те са полезни.
Както обикновено, всички примерни кодове, показани в тази статия, са достъпни в GitHub.