Чому мови програмування дозволяють затіняти / ховати змінні та функції?


31

У багатьох найпопулярніших мовах програмування (такі як C ++, Java, Python тощо) є концепція приховування / затінення змінних чи функцій. Коли я стикався з приховуванням або затіненням, вони стали причиною важких помилок, і я ніколи не бачив випадків, коли я вважав за необхідне використовувати ці функції мов.

Мені здалося б краще заборонити ховатися і затінювати.

Хтось знає про хороше використання цих понять?

Оновлення:
Я не замислююся про інкапсуляцію членів класу (приватних / захищених членів).


Ось чому всі мої імена поля починаються з F.
Пітер Б

7
Я думаю, що Ерік Ліпперт про це мав хорошу статтю. О зачекайте, ось це: blogs.msdn.com/b/ericlippert/archive/2008/05/21/…
Lescai Ionel

1
Будь ласка, уточнюйте своє запитання. Ви запитуєте про приховування інформації взагалі чи про конкретний випадок, описаний у статті Ліпперта, де похідний клас приховує функції базового класу?
Аарон Куртжальс

Важлива примітка. Багато помилок, спричинених приховуванням / затіненням, включають мутацію (встановлення неправильної змінної та цікаво, чому, наприклад, зміни "ніколи не бувають"). Працюючи в основному з незмінними посиланнями, приховування / затінення викликає набагато менше проблем і набагато менше шансів викликати помилки.
Джек

Відповіді:


26

Якщо ви забороняєте ховати та тінятись, у вас є мова, якою всі змінні є глобальними.

Це явно гірше, ніж дозволити локальні змінні чи функції, які можуть приховувати глобальні змінні чи функції.

Якщо ви забороняєте ховати та тінятись, і ви намагаєтесь "захистити" певні глобальні змінні, ви створюєте ситуацію, коли компілятор каже програмісту "Вибачте, Дейв, але ти не можеш використовувати це ім'я, воно вже використовується . " Досвід роботи COBOL показує, що програмісти майже одразу вдаються до нецензурної лексики в цій ситуації.

Принциповим питанням є не приховування / затінення, а глобальні змінні.


19
Ще одним недоліком заборонити тінірування є те, що додавання глобальної змінної може порушити код, оскільки ця змінна вже використовувалася в локальному блоці.
Джорджіо

19
"Якщо ви забороняєте ховати і тіняти, то у вас є мова, якою всі змінні є глобальними." - не обов'язково: ви можете мати змінні масштаби змінних без затінення, і ви це пояснили.
Тіаго Сільва

@ThiagoSilva: І тоді у вашій мові повинен бути спосіб сказати компілятору, що цей модуль МОЖЕ отримати доступ до змінної "frammis" цього модуля. Чи дозволяєте ви комусь приховати / затінити предмет, який він навіть не знає, або ви йому про це скажете, чому йому не дозволяється використовувати це ім'я?
Джон Р. Стром

9
@Phil, вибачте, я не погоджуюся з вами, але ОП запитала про "приховування / затінення змінних чи функцій", а слова "батько", "дитина", "клас" та "член" ніде не виникають у його питанні. Це, мабуть, зробить це загальним питанням щодо сфери назви.
Джон Р. Стром

3
@dylnmc, я не сподівався прожити досить довго, щоб зіткнутися з вигуком, досить молодим, щоб не отримати очевидного посилання "2001: Космічна Одісея".
Джон Р. Стром

15

Хтось знає про хороше використання цих понять?

Використання точних, описових ідентифікаторів завжди корисне.

Я можу стверджувати, що приховування змінної не викликає багато помилок, оскільки наявність двох дуже подібних змінних однакових / подібних типів (що ви зробите, якщо приховування змінної було заборонено), ймовірно, може викликати стільки ж помилок та / або так само, як сильні помилки. Я не знаю, чи правильний цей аргумент , але це, принаймні, правдоподібно.

Використання якогось угорського позначення для диференціації полів від локальних змінних обходить це, але має власний вплив на технічне обслуговування (та санітарність програміста).

І (можливо, найімовірніше, причина, з якої ця концепція відома в першу чергу), мови набагато простіше реалізувати приховування / затінення, ніж забороняти її. Простіша реалізація означає, що компілятори рідше мають помилки. Простіша реалізація означає, що компілятори забирають менше часу на написання, що спричиняє більш раннє та широке прийняття платформи.


3
Насправді, ні, це НЕ простіше здійснити приховування та затінення. Насправді простіше реалізувати "всі змінні є глобальними". Вам потрібен лише один простір імен, і ви завжди Експортуєте ім'я, на відміну від кількох просторів імен та для кожного імені потрібно вирішувати, чи потрібно експортувати його.
Джон Р. Стром

5
@ JohnR.Strohm - Безумовно, але як тільки у вас є якісь масштаби (читайте: класи), то маючи області приховання нижчих областей, ви там безкоштовно.
Теластин

Оцінка та заняття - це різні речі. За винятком BASIC, кожна мова, яку я запрограмував, має масштабування, але не всі вони мають будь-яку концепцію класів чи об'єктів.
Майкл Шоу

@michaelshaw - звичайно, я мав би бути більш чітким.
Теластин

7

Просто для того, щоб переконатися, що ми знаходимося на одній сторінці, метод "приховування" - це те, коли похідний клас визначає однойменний член, як один у базовому класі (який, якщо це метод / властивість, не позначається віртуальним / замінюваним ), і коли викликається екземпляр похідного класу у "похідному контексті", використовується похідний член, тоді як якщо він викликається тим самим екземпляром у контексті його базового класу, використовується базовий клас. Це відрізняється від абстрагування / переосмислення членів, коли член базового класу очікує, що похідний клас визначить заміну, і від модифікаторів обсягу / видимості, які "приховують" члена від споживачів за межами бажаної області.

Коротка відповідь на те, чому це дозволено, полягає в тому, що не робити цього змушує розробників порушувати кілька ключових положень об’єктно-орієнтованого дизайну.

Ось довша відповідь; спочатку розглянемо таку структуру класів у альтернативному всесвіті, де C # не дозволяє ховати членів:

public interface IFoo
{
   string MyFooString {get;}
   int FooMethod();
}

public class Foo:IFoo
{
   public string MyFooString {get{return "Foo";}}
   public int FooMethod() {//incredibly useful code here};
}

public class Bar:Foo
{
   //public new string MyFooString {get{return "Bar";}}
}

Ми хочемо відсторонити члена в Bar і тим самим дозволити Bar надати інший MyFooString. Однак ми не можемо цього зробити, оскільки це порушило б заборону альтернативної прихованості приховування членів. Цей конкретний приклад може бути помилковим для помилок і є яскравим прикладом того, чому ви можете його заборонити; наприклад, який вихід консолі ви отримали, якби зробили наступне?

Bar myBar = new Bar();
Foo myFoo = myBar;
IFoo myIFoo = myFoo;

Console.WriteLine(myFoo.MyFooString);
Console.WriteLine(myBar.MyFooString);
Console.WriteLine(myIFoo.MyFooString);

Вгорі голови я фактично не впевнений, чи отримаєте ви "Foo" або "Bar" на останньому рядку. Ви напевно отримаєте "Foo" для першого рядка та "Bar" для другого, навіть якщо всі три змінні посилаються на абсолютно той самий екземпляр з точно таким же станом.

Отже, дизайнери мови в нашому альтернативному Всесвіті відштовхують цей очевидно поганий код, запобігаючи приховуванню власності. Тепер ви як кодер маєте справжню потребу зробити саме це. Як ти обійдеш обмеження? Ну, один із способів - по-іншому назвати властивість Bar:

public class Bar:Foo
{
   public string MyBarString {get{return "Bar";}}       
}

Ідеально законно, але ми не хочемо поведінки. Екземпляр Bar завжди створюватиме "Foo" для властивості MyFooString, коли ми хотіли, щоб він створював "Bar". Мало того, що ми повинні знати, що наш IFoo - це спеціально бар, ми також мусимо знати інший аксесуар.

Ми також могли, цілком правдоподібно, забути про відносини батько-дитина та реалізувати інтерфейс безпосередньо:

public class Bar:IFoo
{
   public string MyFooString {get{return "Bar";}}
   public int FooMethod() {...}
}

На цей простий приклад - ідеальна відповідь, якщо ви дбаєте лише про те, щоб Фу і Бар були обома IFoos. Код використання, який наведено в декількох прикладах, не вдасться скласти, оскільки бар не є Foo і не може бути призначений як такий. Однак якщо у Foo був якийсь корисний метод "FooMethod", який потрібен Бар, тепер ви не можете успадкувати цей метод; вам або доведеться клонувати його код у Bar, або бути творчим:

public class Bar:IFoo
{
   public string MyFooString {get{return "Bar";}}
   private readonly theFoo = new Foo();

   public int FooMethod(){return theFoo.FooMethod();}
}

Це очевидний злом, і хоча деякі реалізації специфікацій мови OO коштують трохи більше, ніж це, концептуально це неправильно; якщо споживачі Бар необхідності піддавати функціональності F, в барі повинен бути Foo, що не має в Foo.

Очевидно, що якщо ми контролювали Foo, ми можемо зробити його віртуальним, а потім перекрити його. Це концептуальна найкраща практика в нашому нинішньому Всесвіті, коли очікується, що член буде замінений, і він буде мати місце в будь-якому альтернативному Всесвіті, який не дозволяв ховати:

public class Foo:IFoo
{
   public virtual string MyFooString {get{return "Foo";}}
   //...
}

public class Bar:Foo
{
   public override string MyFooString {get{return "Bar";}}
}

Проблема в цьому полягає в тому, що доступ до віртуальних членів під кришкою є порівняно дорожчим, і тому ти, як правило, хочеш це робити лише тоді, коли потрібно. Однак відсутність приховування змушує вас бути песимістичними щодо членів, які інший кодер, який не контролює ваш вихідний код, може захотіти повторно виконувати; "найкращою практикою" для будь-якого класу, який не є запечатаним, було б зробити все віртуальним, якщо ви спеціально не хотіли, щоб це було. Це також все ще не дає точної поведінки приховування; рядок завжди буде "Bar", якщо екземпляр є Bar. Іноді справді корисно використовувати шари прихованих даних про стан, виходячи з рівня спадкування, на якому ви працюєте.

Підсумовуючи це, дозволяти членам ховатися є меншим з цих злом. Його відсутність, як правило, призведе до більш жорстоких злодійств, вчинених на об'єктно-орієнтованих принципах, ніж це дозволяють.


+1 для вирішення актуального питання. Хороший приклад реального світу використання перегонів членів є IEnumerableі IEnumerable<T>інтерфейсом, описаний в Eric Libbert в блозі на цю тему.
Філ

Переосмислення не приховує. Я не згоден з @Phil, що це стосується питання.
Ян Худек

Моя думка полягала в тому, що переосмислення може бути заміною приховування, коли приховування не є можливим. Я погоджуюся, це не приховує, і я кажу так само в першому абзаці. Жоден із обхідних варіантів мого сценарію альтернативної реальності, що не ховається в C #, не ховається; в тім-то й річ.
КітС

Мені не подобається, як ти використовуєш тінь / приховування. Основні переваги, які я бачу, - це (1) хитрощі навколо ситуації, коли нова версія базового класу включає члена, який конфліктує зі споживчим кодом, розробленим на старій версії [негарна, але необхідна]; (2) підроблені речі, такі як коваріація типу повернення; (3) розгляд випадків, коли метод базового класу може бути використаний для певного підтипу, але не є корисним . LSP вимагає першого, але не вимагає останнього, якщо в договорі базового класу визначено, що деякі методи можуть не бути корисними в деяких умовах.
supercat

2

Чесно кажучи, Ерік Ліпперт, розробник принципів у команді компілятора C #, це досить добре пояснює (дякую Лешке Іонелю за посилання). .NET IEnumerableта IEnumerable<T>інтерфейси - хороші приклади, коли приховування членів корисно.

У перші дні .NET у нас не було дженериків. Так IEnumerableінтерфейс виглядав так:

public interface IEnumerable
{
    IEnumerator GetEnumerator();
}

Цей інтерфейс дозволяє нам foreachнад колекцією об'єктів, однак нам потрібно було передати всі ці об’єкти, щоб правильно їх використовувати.

Потім прийшли дженерики. Коли ми отримали дженерики, ми також отримали новий інтерфейс:

public interface IEnumerable<T> : IEnumerable
{
    IEnumerator<T> GetEnumerator();
}

Тепер нам не потрібно кидати об'єкти, поки ми повторюємо їх! Вуто! Тепер, якщо приховування учасників не було дозволено, інтерфейс повинен був виглядати приблизно так:

public interface IEnumerable<T> : IEnumerable
{
    IEnumerator<T> GetEnumeratorGeneric();
}

Це було б нерозумно, бо GetEnumerator()і GetEnumeratorGeneric()в обох випадках робиться майже однаково те саме , але вони мають дещо різні значення повернення. Насправді вони настільки схожі, що ви майже завжди хочете замовчуватись на загальну форму GetEnumerator, якщо тільки ви не працюєте зі застарілим кодом, який був написаний до введення дженериків у .NET.

Іноді член приховування робить , щоб більше місця для противного коду і важкодоступних знайти помилки. Однак іноді це корисно, наприклад, коли ви хочете змінити тип повернення, не порушуючи застарілого коду. Ось тільки одне з таких рішень, які мають зробити мовні дизайнери: чи ми незручні тим розробникам, які законно потребують цю функцію, і залишаємо її поза межами, чи ми включаємо цю функцію в мову і вловлюємо моменти у тих, хто є жертвами її неправильного використання?


Хоча формально IEnumerable<T>.GetEnumerator()приховує IEnumerable.GetEnumerator(), це лише тому, що C # не має коваріантних типів повернення при переопределенні. Логічно це перебор, повністю відповідає LSP. Приховування - це те, що у вас є локальна змінна mapфункція у файлі, який це робить using namespace std(у C ++).
Ян Худек

2

Ваше запитання можна прочитати двома способами: або ви запитуєте про область змінної / функції в цілому, або ви задаєте більш конкретний питання про сферу застосування в ієрархії спадкування. Ви конкретно не згадали про спадщину, але ви згадали важко знайти помилки, що в контексті спадкування звучить скоріше як сфера застосування, ніж звичайна сфера, тому я відповім на обидва питання.

Взагалі сфера застосування - це гарна ідея, оскільки вона дозволяє нам зосередити свою увагу на одній конкретній (сподіваємось невеликій) частині програми. Оскільки це дозволяє локальним іменам завжди вигравати, якщо ви читаєте лише ту частину програми, яка знаходиться в заданому обсязі, то ви точно знаєте, які частини були визначені локально, а що було визначено в інших місцях. Або ім'я посилається на щось місцеве, і в цьому випадку код, який визначає це прямо перед вами, або це посилання на щось, що знаходиться поза локальною сферою. Якщо немає жодних не локальних посилань, які могли б змінитися з-під нас (особливо глобальних змінних, які можна змінити з будь-якого місця), то ми можемо оцінити, правильна чи ні частина програми в локальному масштабі без посилання до будь-якої частини решти програми взагалі .

Це може іноді призводити до кількох помилок, але це більше, ніж компенсує, запобігаючи величезній кількості інших можливих помилок. Окрім створення локального визначення з тим самим іменем, що і функція бібліотеки (не робіть цього), я не можу побачити простий спосіб ввести помилки з локальним діапазоном, але локальне поле - це те, що дозволяє використовувати багато частин однієї програми Я як лічильник індексу для циклу, не розтушовуючи один одного, і дозволяє Фреду вниз по холу записати функцію, яка використовує рядок з ім'ям str, який не буде клобувати вашу рядок з тим же ім'ям.

Я знайшов цікаву статтю Бертрана Мейєра, яка обговорює перевантаження в контексті спадкування. Він викликає цікаве розмежування між тим, що він називає синтаксичним перевантаженням (мається на увазі, що є дві різні речі з однаковою назвою) і семантичним перевантаженням (мається на увазі, що є дві різні реалізації однієї абстрактної ідеї). Семантичне перевантаження було б добре, оскільки ви мали намір по-різному реалізувати його в підкласі; синтаксична перевантаження - це випадкове зіткнення імені, яке спричинило помилку.

Різниця між перевантаженням у ситуації спадкування, яка призначена і яка є помилкою, - це семантика (значення), тому компілятор не може знати, правильно чи неправильно те, що ви зробили. У простому контексті обгрунтування правильна відповідь - це завжди місцева річ, тому компілятор може зрозуміти, що таке правильне.

Пропозиція Бертран Мейєра полягала б у використанні такої мови, як Ейфелева, яка не дозволяє зіткнення імен на зразок цієї і змушує програміста перейменовувати одне чи обидва, тим самим уникаючи проблеми. Моя пропозиція полягала б у тому, щоб не використовувати спадщину цілком, а також уникати проблеми цілком. Якщо ви не можете або не хочете робити жодної з цих речей, все ж є такі речі, щоб зменшити ймовірність виникнення проблем із спадщиною: дотримуйтесь LSP (Лісковського принципу заміни), віддайте перевагу складу над спадщиною, зберігайте ваші ієрархії спадкування дрібні, а класи зберігайте в ієрархії спадкування невеликими. Крім того, деякі мови можуть мати можливість попередити, навіть якщо вони не видадуть помилку, як мова, як Ейфелева.


2

Ось два мої центи.

Програми можуть бути структуровані в блоки (функції, процедури), які є самостійними одиницями логіки програми. Кожен блок може посилатися на "речі" (змінні, функції, процедури), використовуючи імена / ідентифікатори. Таке відображення від імен до речей називається обов'язковим .

Імена, які використовується блоком, поділяються на три категорії:

  1. Локально визначені імена, наприклад локальні змінні, які відомі лише всередині блоку.
  2. Аргументи, прив’язані до значень, коли викликається блок і можуть використовуватися абонентом, щоб вказати параметр вводу / виводу блоку.
  3. Зовнішні імена / прив’язки, які визначені в середовищі, в якому знаходиться блок, і знаходяться в межах блоку.

Розглянемо для прикладу наступну програму C

#include<stdio.h>

void print_double_int(int n)
{
  int d = n * 2;

  printf("%d\n", d);
}

int main(int argc, char *argv[])
{
  print_double_int(4);
}

Функція print_double_intмає локальну назву (локальна змінна) dта аргумент nі використовує зовнішнє, глобальне ім'я printf, яке знаходиться в області дії, але не визначено локально.

Зауважте, що printfтакож можна подати як аргумент:

#include<stdio.h>

void print_double_int(int n, int printf(const char *, ...))
{
  int d = n * 2;

  printf("%d\n", d);
}

int main(int argc, char *argv[])
{
  print_double_int(4, printf);
}

Зазвичай аргумент використовується для введення параметрів вводу / виводу функції (процедура, блок), тоді як глобальні імена використовуються для позначення таких речей, як функції бібліотеки, які "існують у середовищі", і тому їх зручніше згадувати лише тоді, коли вони потрібні. Використання аргументів замість глобальних імен є основною ідеєю введення залежності , яка використовується тоді, коли залежності потрібно робити явними, а не вирішувати, дивлячись на контекст.

Ще одне подібне використання зовнішньо визначених імен можна знайти у закриттях. У цьому випадку ім'я, визначене в лексичному контексті блоку, може використовуватися всередині блоку, і значення, пов'язане з цим іменем, буде (як правило) існувати до тих пір, поки блок посилається на нього.

Візьмемо для прикладу цей код Scala:

object ClosureExample
{
  def createMultiplier(n: Int) = (m: Int) => m * n

  def main(args: Array[String])
  {
    val multiplier3 = createMultiplier(3)
    val multiplier5 = createMultiplier(5)

    // Prints 6.
    println(multiplier3(2))

    // Prints 10.
    println(multiplier5(2))
  }
}

Зворотним значенням функції createMultiplierє закриття (m: Int) => m * n, яке містить аргумент mта зовнішнє ім'я n. Ім'я nвирішується, переглядаючи контекст, в якому визначено закриття: ім'я пов'язане з аргументом nфункції createMultiplier. Зауважте, що це прив'язка створюється під час створення закриття, тобто коли createMultiplierвикликається. Отже, ім'я nпов'язане з фактичним значенням аргументу для певного виклику функції. Порівнюйте це з випадком такої функції бібліотеки printf, яка вирішується лінкером при побудові виконавчого файлу програми.

Узагальнюючи, може бути корисним посилання на зовнішні імена всередині локального блоку коду, щоб ви

  • не потрібно / хочу передавати зовні визначені імена явно як аргументи, і
  • ви можете заморозити прив’язки під час виконання під час створення блоку, а потім отримати доступ до нього пізніше, коли блок викликається.

Shadowing виникає, коли ви вважаєте, що в блоці вас цікавлять лише відповідні імена, визначені в оточенні, наприклад, printfфункція, яку ви хочете використовувати. Якщо випадково ви хочете використовувати локальне ім'я ( getc, putc, scanf...) , який вже використовується в середовищі, ви просто хочете ігнорувати (тінь) глобальне ім'я. Тож, думаючи локально, не хочеться враховувати весь (можливо, дуже великий) контекст.

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

Підсумок: якщо ви хочете, щоб блок коду посилався на зовнішньо визначені прив'язки, вам потрібно тініти, щоб захистити локальні імена від глобальних.

Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.