Към съдържанието
wannacode

Функции: една работа, малко параметри, без скрити странични ефекти

Накратко: Добрата функция върши една работа на едно ниво на абстракция, има малко параметри и без булеви флагове и не сменя тихо състояние, което името ѝ не обещава.

Проблем

Функцията, която прави всичко

Имената от миналия урок вече са на място. Константите на магазина:

static final double FREE_DELIVERY_FROM = 50;
static final double STANDARD_DELIVERY_FEE = 4.99;
static final double EXPRESS_SURCHARGE = 7;
static final String SPRING_COUPON = "SPRING10";
static final double SPRING_DISCOUNT = 0.10;
static List<Double> orders = new ArrayList<>();

А това е функцията, през която минава всяка поръчка:

static void processOrder(String email, double[] prices, String coupon, boolean express) {
double amount = 0;
for (double price : prices) {
amount += price;
}
if (coupon.equals(SPRING_COUPON)) {
amount = amount * (1 - SPRING_DISCOUNT);
}
double delivery = amount >= FREE_DELIVERY_FROM ? 0 : STANDARD_DELIVERY_FEE;
if (express) {
delivery += EXPRESS_SURCHARGE;
}
double total = amount + delivery;
orders.add(total);
System.out.printf("До %s: поръчката е приета, %.2f €%n", email, total);
System.out.printf("[log] поръчка №%d за %.2f €%n", orders.size(), total);
}
processOrder("maria@example.com", new double[] {24.50, 18.00}, "SPRING10", true);
processOrder("ivan@example.com", new double[] {64.00}, "", false);
Изход
До maria@example.com: поръчката е приета, 50.24 €
[log] поръчка №1 за 50.24 €
До ivan@example.com: поръчката е приета, 64.00 €
[log] поръчка №2 за 64.00 €

Работи. Сега маркетингът иска купонът да не важи за доставката. Счетоводството иска таксата да се показва отделно в количката. Тестерът иска да провери доставката под и над 50 €, а за всяка проверка трябва да направи поръчка, тоест да я запише и да прати имейл. Всички пипат една и съща функция. А какво значи true в края на извикването, пак не се вижда.

Идеята

Една работа и едно ниво

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

Правилото е една работа на функция. Функцията с една работа е малка, има име, което казва цялата работа, и малко параметри. Как разбираш, че работата е една:

  • описваш я с едно изречение без „и“;
  • от нея не може да се извади парче с име, което не е просто преразказ на кода.

Второто правило е едно ниво на абстракция. Горната функция се чете като съдържание на книга: смятай сумата, запиши, потвърди, запиши в лога. Как точно се събират цените (циклите, сравненията, форматът на низа) е едно ниво по-долу, в малките функции. Когато в един и същ метод са „запиши поръчката“ и amount += price, читателят прескача между двете нива на всеки ред.

Виж го

Същата поръчка, разделена

Отгоре е съдържанието:

static void placeOrder(String email, double[] prices, String coupon, boolean express) {
double total = orderTotal(prices, coupon, express);
orders.add(total);
sendConfirmation(email, total);
logOrder(orders.size(), total);
}
static void sendConfirmation(String email, double total) {
System.out.printf("До %s: поръчката е приета, %.2f €%n", email, total);
}
static void logOrder(int number, double total) {
System.out.printf("[log] поръчка №%d за %.2f €%n", number, total);
}

Под него е сметката, всяка работа в своя функция:

static double subtotal(double[] prices) {
double amount = 0;
for (double price : prices) {
amount += price;
}
return amount;
}
static double applyCoupon(double amount, String coupon) {
if (coupon.equals(SPRING_COUPON)) {
return amount * (1 - SPRING_DISCOUNT);
}
return amount;
}
static double deliveryFee(double amount, boolean express) {
double fee = amount >= FREE_DELIVERY_FROM ? 0 : STANDARD_DELIVERY_FEE;
if (express) {
fee += EXPRESS_SURCHARGE;
}
return fee;
}
static double orderTotal(double[] prices, String coupon, boolean express) {
double amount = subtotal(prices);
double discounted = applyCoupon(amount, coupon);
return discounted + deliveryFee(discounted, express);
}
placeOrder("maria@example.com", new double[] {24.50, 18.00}, "SPRING10", true);
placeOrder("ivan@example.com", new double[] {64.00}, "", false);
Изход
До maria@example.com: поръчката е приета, 50.24 €
[log] поръчка №1 за 50.24 €
До ivan@example.com: поръчката е приета, 64.00 €
[log] поръчка №2 за 64.00 €

Изходът е същият. Сега купонът е в една функция от три реда, доставката в друга, и всяка се сменя, без да се пипа имейлът. Кодът е повече на брой редове, но всяко парче се чете, сменя и проверява отделно.

По-точно, за любопитните

Колко малка? Няма число. Функция от 30 реда, която прави едно нещо (например дълъг, но равен switch с еднакви клонове), е наред. Функция от 8 реда с три нива на влагане и два флага не е. Мерилото е описанието с едно изречение, не броят на редовете. Разделянето на дълга функция на стъпки, така че програмата да работи след всяка, е Extract Method в шестия урок от модула.

Пробвай

Подреди редовете

Подреди orderTotal така, че купонът да се приложи преди доставката. Защо редът има значение: отстъпката може да свали поръчката под 50 € и тогава доставката вече не е безплатна.

  1. return discounted + deliveryFee(discounted, express);
  2. }
  3. double amount = subtotal(prices);
  4. static double orderTotal(double[] prices, String coupon, boolean express) {
  5. double discounted = applyCoupon(amount, coupon);

Идеята

Малко параметри и без флагове

Всеки параметър е нещо, което читателят трябва да държи в главата си, и нещо, което всяко извикване трябва да подаде в правилния ред. Нула, един и два параметъра се четат лесно. Три вече е много. Четири и повече обикновено значат, че параметрите са едно нещо, разпиляно на части: имейлът, цените, купонът и доставката заедно са поръчка. Тогава те стават един обект. Това е refactoring-ът Introduce Parameter Object от шестия урок.

Най-лошият параметър е флагът: булева стойност, която избира какво да прави функцията.

static void printSlip(double[] prices, boolean forCourier) {
if (forCourier) {
System.out.println("Пратка: " + prices.length + " бр.");
} else {
for (double price : prices) {
System.out.printf("%.2f €%n", price);
}
}
}
double[] cart = new double[] {24.50, 18.00};
printSlip(cart, true);
printSlip(cart, false);
Изход
Пратка: 2 бр.
24.50 €
18.00 €

При извикването true не казва нищо. А вътре функцията има два отделни пътя, тоест две работи. Поправката е две функции с имена:

static void printCourierSlip(double[] prices) {
System.out.println("Пратка: " + prices.length + " бр.");
}
static void printReceipt(double[] prices) {
for (double price : prices) {
System.out.printf("%.2f €%n", price);
}
}
double[] cart = new double[] {24.50, 18.00};
printCourierSlip(cart);
printReceipt(cart);
Изход
Пратка: 2 бр.
24.50 €
18.00 €

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

В Java аргументите нямат имена при извикването. Остава променлива с име, а за повече параметри обект, който ги събира (или Builder от модула за шаблоните):

boolean express = true;
System.out.printf("%.2f €%n", deliveryFee(38.25, express));
Изход
11.99 €

Идеята

Страничен ефект

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

Страничните ефекти не са лоши сами по себе си. Поръчката трябва да се запише някъде, а имейлът трябва да тръгне. Лошо е, когато ефектът е скрит: името казва „питам“, а функцията и прави. Който я вика, не очаква нищо да се смени и я вика спокойно два пъти.

Виж го

Въпросът, който сменя отговора

Складът казва дали има лампи. Някой е решил, че щом има, е удобно веднага да ги запази:

class Warehouse {
private int stock;
public Warehouse(int stock) {
this.stock = stock;
}
public boolean isInStock(int quantity) {
if (stock >= quantity) {
stock -= quantity;
return true;
}
return false;
}
public int getStock() {
return stock;
}
}

Количката пита, за да покаже „има“. После плащането пита същото:

Warehouse lamps = new Warehouse(3);
System.out.println("Количка: " + (lamps.isInStock(2) ? "има" : "няма"));
System.out.println("Плащане: " + (lamps.isInStock(2) ? "има" : "няма"));
System.out.println("На склад: " + lamps.getStock());
Изход
Количка: има
Плащане: няма
На склад: 1

class Warehouse {
private int stock;
public Warehouse(int stock) {
this.stock = stock;
}
public boolean isInStock(int quantity) {
if (stock >= quantity) {
stock -= quantity;
return true;
}
return false;
}
public int getStock() {
return stock;
}
}
Warehouse lamps = new Warehouse(3);
System.out.println("Количка: " + (lamps.isInStock(2) ? "има" : "няма"));
System.out.println("Плащане: " + (lamps.isInStock(2) ? "има" : "няма"));
System.out.println("На склад: " + lamps.getStock());
Стъпка по стъпка, с думи
  1. Складът има 3 лампи на рафта.
  2. Количката иска само да покаже дали има 2. Тя пита isInStock(2): въпрос, съдейки по името.
  3. 3 >= 2: има достатъчно.
  4. Но въпросът и взима лампите: stock става 1 от 3. Никой нищо не е купил, а количката не знае, че това е станало.
  5. Количката получава true и показва „има“.
  6. Клиентът отива към плащане. Плащането пита същото: isInStock(2).
  7. 1 >= 2 е false. Същият въпрос получава друг отговор, а клиентът вижда „няма“.
  8. На склад: 1. Отговорът се смени, защото първият въпрос имаше скрит страничен ефект.

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

По-точно, за любопитните
Още по-коварно е в getter: getStock(), който сменя нещо, изглежда като четене на поле. IntelliJ IDEA извиква toString() на обектите, за да ги покаже в дебъгера, затова и toString() със страничен ефект сменя състоянието само защото си спрял на точка за прекъсване.

Пробвай

Предвиди изхода

Складът с isInStock, който и запазва, вече има 5 лампи. Един и същ въпрос се задава три пъти. Какво печата програмата?

Warehouse lamps = new Warehouse(5);
System.out.println(lamps.isInStock(2));
System.out.println(lamps.isInStock(2));
System.out.println(lamps.isInStock(2));
Отговор

Идеята

Команда или въпрос, не и двете

Правилото се казва разделяне на команда и въпрос (command–query separation): функцията или отговаря на въпрос и не сменя нищо, или прави нещо и не се преструва на въпрос. Складът става така:

class Warehouse {
private int stock;
public Warehouse(int stock) {
this.stock = stock;
}
public boolean isInStock(int quantity) {
return stock >= quantity;
}
public void reserve(int quantity) {
if (!isInStock(quantity)) {
throw new IllegalStateException("Няма достатъчно наличност");
}
stock -= quantity;
}
public int getStock() {
return stock;
}
}
Warehouse lamps = new Warehouse(3);
System.out.println("Количка: " + (lamps.isInStock(2) ? "има" : "няма"));
System.out.println("Плащане: " + (lamps.isInStock(2) ? "има" : "няма"));
lamps.reserve(2);
System.out.println("На склад: " + lamps.getStock());
Изход
Количка: има
Плащане: има
На склад: 1

Количката и плащането питат колкото искат. Лампите се запазват на един ред, който казва точно това: reserve(2).

По-точно, за любопитните

Има известни изключения, и името ги издава: stack.pop() маха елемента и го връща, iterator.next() мести напред и връща. Това са глаголи, не въпроси, затова никой не ги вика „просто да провери“. Правилото не е „командата никога не връща нищо“, а „въпросът никога не сменя нищо“.

Идеята

Чистата функция

deliveryFee от разделената поръчка отговаря само по параметрите си и не сменя нищо. Такава функция се казва чиста: при едни и същи параметри винаги връща едно и също, колкото и пъти и в какъвто и ред да я викаш.

System.out.printf("%.2f €%n", deliveryFee(38.25, false));
System.out.printf("%.2f €%n", deliveryFee(38.25, true));
System.out.printf("%.2f €%n", deliveryFee(64.00, false));
System.out.printf("%.2f €%n", deliveryFee(38.25, true));
Изход
4.99 €
11.99 €
0.00 €
11.99 €

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

Пробвай

Намери грешката

Бутонът „Махни последното“ трябва да маха продукта, който клиентът е добавил последен. Клиентът добавя мляко, ябълки и хляб. Преди това магазинът печата бележка, подредена по азбучен ред:

static void printReceipt(List<String> items) {
Collections.sort(items);
for (String item : items) {
System.out.println(" " + item);
}
}
List<String> cart = new ArrayList<>(List.of("мляко", "ябълки", "хляб"));
printReceipt(cart);
System.out.println("Последно добавено: " + cart.get(cart.size() - 1));
Изход
мляко
хляб
ябълки
Последно добавено: ябълки

Последен е добавен хлябът, а програмата казва ябълки. На кой ред е грешката?

Избери реда с грешката.

Упражнение

Известието за изпратена поръчка

Реши задачата в редактора си.

Известието. Тази функция смята текста, избира канала с флаг, печата и брои изпратените:

static int sent = 0;
static void notifyShipped(String name, int orderId, boolean sms) {
sent++;
String text = "Здравей, " + name + "! Поръчка " + orderId + " е изпратена.";
if (sms) {
System.out.println("SMS: " + text);
} else {
System.out.println("Имейл: " + text);
}
}
notifyShipped("Мария", 1042, false);
notifyShipped("Иван", 1043, true);
System.out.println("Изпратени: " + sent);
Изход
Имейл: Здравей, Мария! Поръчка 1042 е изпратена.
SMS: Здравей, Иван! Поръчка 1043 е изпратена.
Изпратени: 2

Раздели я на три функции: чиста, която само прави текста, и две команди, едната праща по имейл, другата по SMS, и всяка брои изпратеното. Флагът изчезва. Изходът трябва да остане същият:

Изход
Имейл: Здравей, Мария! Поръчка 1042 е изпратена.
SMS: Здравей, Иван! Поръчка 1043 е изпратена.
Изпратени: 2

Още задачи

  • Параметър обект. placeOrder има четири параметъра. Събери ги в клас Order и виж как изглежда извикването.
  • Купонът не важи за доставката. Маркетингът сменя правилото. Коя функция пипаш и колко други остават непокътнати?
  • Опашката. Напиши peekNext() и takeNext() за опашка от поръчки за куриера. Кое е въпрос и кое е команда?

Тест

Провери се

1. Как разбираш, че processOrder прави повече от една работа?
2. printSlip(cart, true). Какво не е наред?
3. isInStock(2) намалява наличността, ако има достатъчно. Количката пита веднъж, плащането още веднъж. Какво става при 3 лампи?
4. Коя от тези функции е чиста?
5. Какво казва разделянето на команда и въпрос?

Създаден от Иво Гергов като част от кампанията на Evo Tech за достъпно обучение по програмиране ·