Почему языки программирования позволяют скрывать / скрывать переменные и функции?

31

Многие из самых популярных языков программирования (такие как C ++, Java, Python и т. Д.) Имеют концепцию скрытия / теневого копирования переменных или функций. Когда я сталкивался с сокрытием или затенением, они стали причиной трудностей при поиске ошибок, и я никогда не видел случая, когда я считал необходимым использовать эти возможности языков.

Мне было бы лучше запретить прятаться и скрывать.

Кто-нибудь знает о хорошем использовании этих понятий?

Обновление:
я не ссылаюсь на инкапсуляцию членов класса (частных / защищенных членов).

Саймон
источник
Вот почему все мои имена полей начинаются с F.
Pieter B
7
Я думаю, что у Эрика Липперта была хорошая статья на эту тему. Ой, подождите, вот оно: blogs.msdn.com/b/ericlippert/archive/2008/05/21/…
Lescai Ionel
1
Пожалуйста, уточните свой вопрос. Вы спрашиваете о скрытой информации в целом или о конкретном случае, описанном в статье Липперта, где производный класс скрывает функции базового класса?
Аарон Курцхалс
Важное примечание: многие ошибки, вызванные сокрытием / затенением, связаны с мутацией (например, установка неправильной переменной и удивление, почему изменение «никогда не происходит»). При работе, в основном, с неизменяемыми ссылками, сокрытие / затенение вызывает гораздо меньше проблем и с гораздо меньшей вероятностью вызывает ошибки.
Джек

Ответы:

26

Если вы не разрешаете скрывать и скрывать, то у вас есть язык, на котором все переменные являются глобальными.

Это явно хуже, чем разрешать локальные переменные или функции, которые могут скрывать глобальные переменные или функции.

Если вы не разрешаете скрывать и скрывать, И вы пытаетесь «защитить» определенные глобальные переменные, вы создаете ситуацию, когда компилятор говорит программисту: «Извините, Дейв, но вы не можете использовать это имя, оно уже используется «. Опыт работы с COBOL показывает, что программисты практически сразу прибегают к ненормативной лексике в этой ситуации.

Фундаментальная проблема не в сокрытии / теневом копировании, а в глобальных переменных.

Джон Р. Штром
источник
19
Еще один недостаток запрета теневого копирования заключается в том, что добавление глобальной переменной может привести к нарушению кода, поскольку переменная уже использовалась в локальном блоке.
Джорджио
19
«Если вы не разрешаете скрывать и скрывать, то у вас есть язык, в котором все переменные являются глобальными». - не обязательно: вы можете иметь переменные в области видимости без затенения, и вы это объяснили.
Тьяго Силва
@ThiagoSilva: И тогда ваш язык должен иметь способ сообщить компилятору , что этот модуль IS разрешен доступ к этому переменный модулю «frammis». Вы позволяете кому-то скрывать / скрывать объект, которого он даже не знает, существует, или вы говорите ему об этом, чтобы сказать ему, почему ему не разрешают использовать это имя?
Джон Р. Штром
9
@Phil, извините за несогласие с вами, но ОП спросил о «сокрытии / скрытии переменных или функций», и слова «родитель», «ребенок», «класс» и «член» нигде не встречаются в его вопросе. Казалось бы, это делает общий вопрос о области имен.
Джон Р. Штром
3
@dylnmc, я не ожидал, что проживу достаточно долго, чтобы встретить достаточно юного болтуну, чтобы не получить очевидную ссылку "2001: Космическая одиссея".
Джон Р. Штром
15

Кто-нибудь знает о хорошем использовании этих понятий?

Использование точных описательных идентификаторов всегда полезно.

Я могу утверждать, что скрытие переменных не вызывает много ошибок, так как наличие двух очень похожих имен переменных одного и того же / похожих типов (что бы вы сделали, если бы скрытие переменных было запрещено) может вызвать столько же ошибок и / или серьезные ошибки Я не знаю, верен ли этот аргумент , но он, по крайней мере, правдоподобен.

Использование некоторой венгерской нотации для дифференциации полей по сравнению с локальными переменными позволяет обойти это, но оказывает свое влияние на обслуживание (и здравомыслие программиста).

И (возможно, наиболее вероятно, причина, по которой концепция известна в первую очередь), для языков гораздо проще реализовать скрытие / теневое копирование, чем запретить его. Более простая реализация означает, что компиляторы с меньшей вероятностью будут иметь ошибки. Более простая реализация означает, что компиляторам требуется меньше времени для написания, что приводит к более раннему и широкому внедрению платформы.

Telastyn
источник
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» для второй, хотя все три переменные ссылаются на один и тот же экземпляр с абсолютно одинаковым состоянием.

Таким образом, разработчики языка в нашей альтернативной вселенной препятствуют этому явно плохому коду, предотвращая скрытие свойств. Теперь вам, как программисту, действительно необходимо сделать именно это. Как вы обходите ограничения? Ну, один из способов - назвать собственность Бара по-другому:

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

Совершенно законно, но это не то поведение, которое мы хотим. Экземпляр Bar всегда будет выдавать «Foo» для свойства MyFooString, когда мы хотели, чтобы он производил «Bar». Мы должны не только знать, что наш IFoo - это конкретно Bar, мы также должны знать, как использовать другой метод доступа.

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

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

Для этого простого примера это идеальный ответ, если вам важно, чтобы Foo и Bar были IFoo. Код использования, приведенный в нескольких примерах, не скомпилируется, потому что Bar не является Foo и не может быть назначен как таковой. Однако, если у Foo есть какой-то полезный метод "FooMethod", который нужен Bar, теперь вы не можете наследовать этот метод; вам придется либо клонировать его код в Bar, либо проявить творческий подход:

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

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

Это очевидный взлом, и хотя некоторые реализации языковых спецификаций ОО составляют чуть больше этого, концептуально это неправильно; если потребители Bar должны раскрыть функциональность Foo, Bar должен быть 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. Иногда действительно полезно использовать слои скрытых данных о состоянии, основываясь на уровне наследования, на котором вы работаете.

Таким образом, разрешение скрывать членов является меньшим из этих зол. Отсутствие этого, как правило, ведет к худшим злодеяниям, совершаемым против объектно-ориентированных принципов, чем допускает это.

Keiths
источник
+1 за решение актуального вопроса. Хорошим примером реального использования скрытия членов является интерфейс IEnumerableand IEnumerable<T>, описанный в блоге Эрика Либберта на эту тему.
Фил
Переопределение не скрывается. Я не согласен с @Phil, что это решает вопрос.
Ян Худек
Моя точка зрения заключалась в том, что переопределение будет заменой скрытия, когда скрытие не вариант. Я согласен, это не сокрытие, и я говорю так же в самом первом абзаце. Ни один из обходных путей к моему сценарию с альтернативной реальностью не скрывается в C #; в этом-то и дело.
KeithS
Мне не нравятся ваши тени / сокрытия. Основные полезные применения, которые я вижу, это: (1) блуждание вокруг ситуации, когда новая версия базового класса включает член, который конфликтует с потребительским кодом, созданным вокруг более старой версии [уродливо, но необходимо]; (2) притворные вещи, такие как ковариация возвратного типа; (3) иметь дело со случаями, когда метод базового класса может вызываться для определенного подтипа, но бесполезен . LSP требует первого, но не требует второго, если в контракте базового класса указано, что некоторые методы могут быть бесполезны в некоторых условиях.
суперкат
2

Честно говоря, Эрик Липперт, главный разработчик команды компиляторов C #, объясняет это довольно хорошо (спасибо Lescai Ionel за ссылку). .NET IEnumerableи IEnumerable<T>интерфейсы являются хорошими примерами того, когда скрытие членов полезно.

В первые дни .NET у нас не было дженериков. Итак, IEnumerableинтерфейс выглядел так:

public interface IEnumerable
{
    IEnumerator GetEnumerator();
}

Этот интерфейс позволил нам foreachперебрать коллекцию объектов, однако нам пришлось привести все эти объекты, чтобы правильно их использовать.

Затем появились дженерики. Когда мы получили дженерики, мы также получили новый интерфейс:

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

Теперь нам не нужно создавать объекты, пока мы их перебираем! Woot! Теперь, если скрытие членов запрещено, интерфейс должен выглядеть примерно так:

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, которая не будет загромождать вашу строку с тем же именем.

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

Разница между перегрузкой в ​​ситуации наследования, которая предназначена и которая является ошибкой, заключается в семантике (смысле), поэтому у компилятора нет возможности узнать, правильно ли вы сделали или нет. В простой ситуации правильный ответ всегда локальный, поэтому компилятор может выяснить, что является правильным.

Бертран Мейер предложил бы использовать такой язык, как Eiffel, который не допускает столкновения имен, подобные этому, и вынуждает программиста переименовывать одно или оба, тем самым полностью избегая проблемы. Мое предложение состоит в том, чтобы избегать использования наследования полностью, а также полностью избегать проблемы. Если вы не можете или не хотите делать что-либо из этого, есть еще способы уменьшить вероятность возникновения проблемы с наследованием: следуйте LSP (принцип подстановки Лискова), предпочитайте композицию над наследованием, сохраняйте ваша иерархия наследования мала, и классы в иерархии наследования остаются небольшими. Кроме того, некоторые языки могут выдавать предупреждение, даже если они не будут выдавать ошибку, как это сделал бы язык, подобный Eiffel.

Майкл Шоу
источник
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, которая разрешается компоновщиком при сборке исполняемого файла программы.

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

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

Затенение приходит, когда вы считаете, что в блоке вас интересуют только соответствующие имена, которые определены в среде, например, в printfфункции, которую вы хотите использовать. Если случайно вы хотите использовать локальное имя ( getc, putc, scanf...) , который уже используется в среде, вы просто хотите игнорировать (тень) глобальное имя. Поэтому, думая локально, вы не хотите рассматривать весь (возможно, очень большой) контекст.

В другом направлении, думая глобально, вы хотите игнорировать внутренние детали локального контекста (инкапсуляция). Поэтому вам нужно затенение, иначе добавление глобального имени может сломать каждый локальный блок, который уже использовал это имя.

В итоге, если вы хотите, чтобы блок кода ссылался на внешние привязки, вам нужно затенение, чтобы защитить локальные имена от глобальных.

Джорджио
источник