Завистливые функции и неуместная близость
Завистливая функция — поведение рядом не с теми данными; неуместная близость — два класса, лезущие во внутренности друг друга. Лечи оба, перемещая поведение к данным и восстанавливая инкапсуляцию, но не трогай оркестрацию, законно охватывающую несколько объектов.
Ты открываешь метод и видишь, как он тянется через стол: order.lineItems, order.taxRate, order.shippingRegion, order.couponPercent. До своего собственного объекта он почти не дотрагивается — каждая строка про чужие данные. Метод живёт в InvoicePrinter, но думает он про Order. Он в чужом доме.
Это и есть завистливая функция (feature envy), а её уродливая родственница — неуместная близость (inappropriate intimacy): два класса перестали стучаться и просто заходят в чужие спальни — читают приватные поля, меняют внутреннее состояние, каждый рассчитывает на форму другого. Оба запаха — одна болезнь под разными углами: поведение уехало от данных, над которыми оно работает, и за разрыв платят связанностью.
После этого урока ты можешь распознать завистливую функцию по соотношению доступа к данным (метод трогает поля чужого объекта чаще своих собственных), распознать неуместную близость по взаимному приватному доступу между классами, применить перемещение метода (Move Method) и инкапсуляцию, чтобы вернуть поведение туда, где живут его данные, и — senior-часть — отличить настоящую зависть от законной оркестрации, чтобы не запихать сценарий использования внутрь объекта данных только ради соблюдения правила.
Завистливая функция — это метод, которому данные чужого объекта интереснее своих собственных. Диагностика механическая: посчитай поля, которые метод читает. Если большинство из них принадлежат одному другому объекту, метод завидует этому объекту — он хочет быть методом на нём.
class InvoicePrinter {
// envious: every field it touches belongs to `order`, none to `this`
amountDue(order: Order): number {
const subtotal = order.lineItems
.reduce((s, li) => s + li.price * li.qty, 0);
const tax = subtotal * order.taxRate;
const shipping = order.shippingRegion === "intl" ? 25 : 5;
return subtotal + tax + shipping - order.couponPercent * subtotal;
}
}amountDue читает lineItems, taxRate, shippingRegion, couponPercent — четыре куска состояния Order — и ничего от InvoicePrinter. Поведение и данные разделены, а стык между ними теперь стал публичной поверхностью, которую Order обязан выставить наружу. Лечение — переместить метод к данным, которым он завидует.
Лекарство от зависти — перемещение метода (Move Method): перенеси поведение к данным, которым оно завидует. Помести amountDue на Order. Теперь он читает свои поля, Order может держать их приватными, а InvoicePrinter задаёт один вопрос вместо четырёх.
class Order {
// behaviour now sits on the data it uses
amountDue(): number {
const tax = this.subtotal() * this.taxRate;
const shipping = this.shippingRegion === "intl" ? 25 : 5;
return this.subtotal() + tax + shipping - this.couponPercent * this.subtotal();
}
private subtotal(): number {
return this.lineItems.reduce((s, li) => s + li.price * li.qty, 0);
}
}
class InvoicePrinter {
print(order: Order): string {
return `Total: ${order.amountDue().toFixed(2)}`; // one call, zero envy
}
}Связанность измеримо упала: зависимость InvoicePrinter от Order сжалась с четырёх полей до одного метода. taxRate, couponPercent и shippingRegion теперь могут стать приватными — правило, которое их объединяет, живёт вместе с ними. Это говори, не спрашивай (tell-don’t-ask): скажи заказу вычислить сумму к оплате; не выпрашивай его потроха и не считай арифметику снаружи.
Неуместная близость — это взаимная завистливая функция: два класса лезут во внутренности друг друга. Зависть однонаправленна — метод хочет данные чужого объекта. Близость двунаправленна и структурна — A читает/пишет приватное состояние B, а B читает/пишет приватное состояние A. Они становятся единым спутанным узлом, притворяющимся двумя классами.
class Account {
balance = 0;
history: Transaction[] = [];
}
class Transaction {
apply(acct: Account) {
acct.balance += this.amount; // mutating Account's field
acct.history.push(this); // reaching into Account's internals
}
}
class Account2 {
reverse(tx: Transaction) {
this.balance -= tx.amount; // reading Transaction's field
tx.reversed = true; // mutating Transaction's field
}
}Каждый класс рассчитывает на точную форму другого и пишет сквозь неё. Поменяй представление balance в Account (центы? денежный тип? журнал событий?) — и Transaction.apply ломается; поменяй поля Transaction — и Account.reverse ломается. Ни о том, ни о другом нельзя рассуждать, тестировать или развивать поодиночке. Близость и есть связанность.
Восстанови инкапсуляцию: дай каждому классу границу или выдели общую концепцию. Близость растворяют два хода. Первый — замени межобъектный доступ к полям методами, раскрывающими намерение, чтобы ни один класс не зависел от внутренностей другого — только от маленького именованного интерфейса:
class Account {
private balance = 0;
private history: Transaction[] = [];
post(tx: Transaction): void { // Account owns its own mutation
this.balance += tx.signedAmount();
this.history.push(tx);
}
}Transaction теперь выставляет signedAmount(), а Account владеет каждой записью в свои поля; никто не лезет через границу. Второй ход — когда спутанность возникает из-за общей концепции, которой не владеет никто (здесь: проведение изменения баланса вместе с его аудиторским следом), выдели эту концепцию в собственный тип — Posting или Ledger, с которым оба сотрудничают чисто. Senior-эвристика: если перемещение метода просто сдвигает зависть в другую сторону, недостающий элемент обычно — третий объект, которого ещё не существует.
Режим отказа: не переноси оркестрацию, законно охватывающую несколько объектов. Правило «перемести поведение к данным, которым оно завидует» предполагает, что поведение принадлежит данным одного объекта. Но некоторые методы трогают поля многих объектов по замыслу — они координируют рабочий процесс: оформление заказа, которое читает Cart, списывает через PaymentGateway, уменьшает Inventory и пишет Order. Такой метод выглядит завистливым ко всем четырём, но не принадлежит ни одному. Это сценарий использования / сервис, и оркестрация принадлежит уровню выше любого отдельного объекта данных.
// Legitimate orchestration — NOT feature envy. Do not jam this into Cart or Order.
class CheckoutService {
async checkout(cart: Cart, payment: PaymentGateway, inventory: Inventory): Promise<Order> {
const total = cart.total(); // each object does its own work...
await payment.charge(total); // ...the service only sequences them
inventory.reserve(cart.items());
return Order.from(cart);
}
}Признак: сервис выстраивает в последовательность вызовы к объектам, каждый из которых делает свою работу (cart.total(), payment.charge()), тогда как зависть делает работу чужого объекта за него (читает cart.taxRate и считает налог снаружи корзины). Если «починить» оркестратор, запихав его внутрь Cart, ты дашь Cart знание о платежах и складе, которого у него быть не должно — ты обменяешь ложный запах на настоящую связанность. Перемещай поведение к данным; оставляй координацию над данными.
Посмотри, как зависть превращается в чистую границу. Начни с модуля маршрутизации, который завидует Location:
// router.ts — envious: every field belongs to `from`/`to`, none to the router
class Router {
distanceKm(from: Location, to: Location): number {
const R = 6371;
const dLat = (to.lat - from.lat) * Math.PI / 180;
const dLon = (to.lng - from.lng) * Math.PI / 180;
const a = Math.sin(dLat / 2) ** 2 +
Math.cos(from.lat * Math.PI / 180) * Math.cos(to.lat * Math.PI / 180) *
Math.sin(dLon / 2) ** 2;
return R * 2 * Math.asin(Math.sqrt(a));
}
}distanceKm читает lat/lng с двух Location и ничего от Router. Это поведение про локации, застрявшее в роутере. Перемести его на Location, где живут его данные:
// location.ts — behaviour now sits with its data
class Location {
constructor(private lat: number, private lng: number) {}
distanceTo(other: Location): number {
const R = 6371;
const dLat = (other.latRad() - this.latRad());
const dLon = (other.lngRad() - this.lngRad());
const a = Math.sin(dLat / 2) ** 2 +
Math.cos(this.latRad()) * Math.cos(other.latRad()) * Math.sin(dLon / 2) ** 2;
return R * 2 * Math.asin(Math.sqrt(a));
}
private latRad() { return this.lat * Math.PI / 180; }
private lngRad() { return this.lng * Math.PI / 180; }
}
// router.ts — now asks one question
class Router {
routeLength(stops: Location[]): number {
return stops.slice(1).reduce(
(sum, stop, i) => sum + stops[i].distanceTo(stop), 0);
}
}Три выигрыша, все от одного перемещения. lat/lng стали приватными — Router больше не знает, что Location хранит градусы (она могла бы внутри перейти на геохеш, и Router не изменился бы). Работа Router сжалась до оркестрации (routeLength выстраивает вызовы distanceTo) — а это ровно то, где роутер и должен жить, потому что суммирование маршрута законно охватывает много локаций и не принадлежит ни одной. Зависть уехала вниз, на Location; координация осталась наверху, в Router. Это разделение — весь урок в одном диффе.
▸Почему это работает
Почему перемещение метода действительно снижает связанность, а не просто переносит её? Связанность считается числом деталей чужого объекта, которые ты обязан знать. Завистливый amountDue знал четыре поля Order; после перемещения InvoicePrinter знает один метод Order. Что важно, эти четыре поля теперь можно сделать приватными, а значит Order волен менять как он хранит налог и купоны, ничего не ломая — публичная поверхность сжалась. Move Method не тасует связанность туда-сюда; он превращает широкую зависимость от данных в узкую поведенческую и отдаёт остальное инкапсуляции. Это превращение — много полей наружу, один глагол внутрь — и есть механизм, стоящий за «говори, не спрашивай».
▸Частая ошибка
Соблазнительная ошибка — считать «трогает поля чужого объекта» доказательством зависти и механически перемещать метод. Два ложных срабатывания кусают сеньоров. Первое — оркестрация: сервис, выстраивающий в последовательность cart, payment и inventory, читает много объектов по замыслу — запихни его в один из них, и ты дашь этому объекту знание, которым он держать не должен. Второе — неправильный дом: метод может завидовать объекту B, но, будучи перемещённым, обнаружить, что теперь завидует C — настоящее лечение в недостающей третьей концепции (Posting, Money, Route), от которой оба должны зависеть, а не в перетягивании каната, какой существующий класс приютит метод. Диагностируй намерение (делает ли он работу чужого объекта или лишь выстраивает работу, которую объекты и так делают сами?) прежде, чем тянуться к Move Method.
Метод CheckoutService читает cart.total(), вызывает payment.charge() и вызывает inventory.reserve(). Ревьюер помечает его как завистливую функцию и просит переместить в Cart. Какой ответ самый senior?
Завистливая функция — это метод, которому данные чужого объекта интереснее своих собственных; диагностируй её по соотношению доступа к полям. Неуместная близость — взаимный случай: два класса лезут во внутренности друг друга, сплавляясь в один узел. Оба — поведение, уехавшее от своих данных, и за разрыв платят связанностью. Лечения — перемещение метода (перенеси поведение к данным, которым оно завидует, затем сделай эти поля приватными) и восстановление инкапсуляции (замени межобъектный доступ к полям именованными методами или выдели общую концепцию в третий тип, когда перемещение просто сдвигает зависть вбок). Senior-дисциплина — это режим отказа: метод, который выстраивает в последовательность несколько объектов, каждый из которых делает свою работу, — это оркестрация: сценарий использования или сервис, принадлежащий уровню выше любого отдельного объекта данных. Перемещай поведение к данным; никогда не хорони координацию внутри объекта данных только ради того, чтобы заткнуть правило подсчёта полей.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.