Сообщений 88    Оценка 1078 [+8/-2]         Оценить  
Система Orphus

As is или история о том как не надо писать код

Автор: Владислав Чистяков (VladD2)
Источник: RSDN Magazine #3-2004
Опубликовано: 18.12.2004
Исправлено: 10.12.2016
Версия текста: 1.0
Преамбула
as vs. ()
Принципы безопасного программирования
Неверное использование as и его последствия
Основания для использования as вместо ()
Цена ошибки
Дополнительные аргументы в пользу ()

Преамбула

Понимание необходимости этой статьи пришло ко мне, когда я после бессонной ночи, проведенной за отладкой чужих «багов», читал статью одного из авторов нашего журнала и натолкнулся на засилье операторов as, используемых вместо оператора приведения типов.

В программе не было ни одного приведения типов, но постоянно встречались as. Мои попытки объяснить порочность данной практики были тщетны. Упорство, с которым отстаивалось применение as-ов, было воистину достойно лучшего применения.

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

as vs. ()

Итак, в C# есть два способа приведения типов. Первый – это оператор «(тип)»:

      object obj = "Некий объект";
string str = (string)obj;

Если приведение типов невозможно, и компилятор не может проконтролировать это во время компиляции, то контроль типов (возможности их преобразования) переносится в runtime. CLR предоставляет возможность контроля безопасности приведения типов в runtime-е. Если типы несовместимы, то генерируется исключение InvalidCastException. Таким образом, если не прибегать к unsafe-коду и внешним unmanaged-модулям, .NET гарантирует легкое и быстрое обнаружение и устранение ошибок, связанных с типами.

Второй – это оператор as. Он отличается от () тем, что не генерирует исключение при невозможности приведения типов. Вместо этого он возвращает null. Этот оператор очень удобен в случаях, когда невозможность преобразования предусматривается логикой программы. Оператор as удобен в случае, когда вам передается ссылка на объект некоторого базового класса, и необходимо произвести некоторые действия, только если эта ссылка указывает на объект некоторого типа-наследника. Например, as очень удобен при реализации метода ConvertTo в наследниках TypeConverter:

      public
      override
      object ConvertTo(ITypeDescriptorContext context, 
  CultureInfo culture, object value, Type destinationType)
{
  MyType myObj = value as MyType;
  if (destinationType == typeof(string) && myObj != null) 
  {
    // Преобразовываем myObj в строку.
  }

  returnbase.ConvertTo(context, culture, value, destinationType);
}

Если value не удается преобразовать к типу MyType, вызывается реализация ConvertTo из базового класса, так что генерировать исключение не требуется.

Кроме операторов as и (), C# поддерживает также оператор is. Этот оператор позволяет определить, можно ли привести некоторый тип к другому. В отличие от as и (), он не производит преобразования, а просто возвращает булево значение, говорящее о возможности или невозможности операции приведения. Предыдущий пример можно переписать с использованием оператора is следующим образом:

      public
      override
      object ConvertTo(ITypeDescriptorContext context, 
  CultureInfo culture, object value, Type destinationType)
{
  if (destinationType == typeof(string) && myObj is MyType) 
  {
    MyType myObj = (MyType)value;
    // Преобразовываем myObj в строку.
  }

  returnbase.ConvertTo(context, culture, value, destinationType);
}

Однако такое использование несколько расточительно, так как фактически runtime .NET производит идентификацию типа два раза. Применение оператора is выгодно в тех случаях, когда количество проверок больше, чем количество случаев использования объекта, получающегося в результате приведения типов. Например, если у вас есть ветвистое дерево, в котором нужно найти ветки определенного подтипа и произвести обработку именно этих веток, можно написать следующий код:

      void FindClass(IAstNode root, AstNodeCollection nodes)
{

  foreach (IAstNode node in root.Children)
  {
    if (node is RTypeClass)
    {
      nodes.Add(node);
      FindClass(node, nodes);
    }
    elseif (
         node is RTypeStruct 
      || node is RCompileUnit
      || node is RNamespace)
    {
      // Рекурсивный вызов этой же процедуры.      
      FindClass(node, nodes);
    }
  }
}

Этот код перебирает ветки абстрактного синтаксического дерева (Abstract Syntactic Tree, AST) в поисках классов, и если находит их, то помещает в динамический массив. AST содержит относительно мало ветвей, описывающих классы. Стало быть, количество проверок будет значительно превышать количество обращений к объекту. К тому же коллекция, в которую помещается найденный объект, хранит ссылки на общий базовый интерфейс для всех классов AST, так что приведение в принципе не требуется. В общем, практически идеальный пример применения is. Однако на практике я очень редко встречал подобные примеры. Я много раз порывался использовать оператор is, но каждый раз, обдумав алгоритм более тщательно, заменял его другим решением (обычно использованием оператора as с последующей проверкой результата операции на null).

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

Принципы безопасного программирования

От ошибки никто не застрахован. Ее может совершить и начинающий программист, и матерый профессионал. Однако на практике с ошибками больше возятся начинающие программисты. Конечно, на это влияет натренированность человека, но в первую очередь на уменьшение количества ошибок влияет тот факт, что опытный программист вырабатывает набор правил и подходов, неукоснительное выполнение которых уменьшает вероятность появления ошибок.

Теоретики программирования давно обращают наше внимание на то, что подобные правила можно систематизировать и проверять автоматически (компилятором или средой исполнения). Проверка корректного использования типов, иными словами, типобезопасность, является краеугольным столпом безопасного программирования. В C# и других языках .NET-а (а также его runtime-е) многое сделано для автоматизации процесса проверки типов. Например, в C++ без проблем можно написать следующий код:

      int    i = 123;
void*  p = &i;
double d = *(double*)p;
printf("i = %f", d);

При попытке повторить этот код в безопасном режиме C#:

      int    i  = 123;
object p  = i;
double d = (double)p;
Console.WriteLine("value = {0}", d);

в runtime будет выдано исключение System.InvalidCastException. Таким образом язык и runtime защищают от ошибок, связанных с приведением типов.

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

Неверное использование as и его последствия

Итак, перейдем к сути статьи.

Рассмотрим следующий пример:

      class A { }

class B : A
{
  publicvoid SomeMethod() {  }
}

class Program
{
  staticvoid Main()
  {
    A a = new B();
    //A a = new A();
    ...
    (a as B).SomeMethod();
  }
}

В этом коде все технически верно. Однако столь простые случаи в программировании – редкость. Очень часто бывает, что переменная инициализируется задолго до ее использования и предположение о том, что в ней находится объект нужного нам типа, может оказаться неверным. В случае ошибки приведение типов возвратит null, и программа вместо фактической ошибки сообщит пользователю, что произошло обращение по нулевому адресу (NullReferenceException). Если ошибочную ситуацию легко воспроизвести, и программист, имеющий доступ к исходникам программы находится в пределах досягаемости, то особых проблем не возникает. Программист отловит исключение в отладчике, поймет, что в переменной находится ссылка на объект не того типа и исправит ошибку.

Хуже, если исходников или программиста под рукой не оказалось. Еще хуже, если ошибка затесалась в редко используемом месте и ее не удается легко воспроизвести. Над такой ошибкой можно посидеть подольше. Однако стоит ее воспроизвести, как проблема исчезает сама собой.

Усложним пример:

      class A { }

class B : A
{
  publicvoid SomeMethod() { }
}

class Test
{
  static B _b;

  staticvoid Init(A a)
  {
    _b = a as B;
  }

  staticvoid Main()
  {
    Init(new A());
    // ...
    _b.SomeMethod();
  }
}

Как видите, ситуация та же. Вместо нормального приведения типа по-прежнему используется оператор as, но в этот раз оператор используется задолго до вызова метода объекта B, а результат операции as помещается в поле класса. При этом в момент возникновения исключения NullReferenceException первопричины ошибки не видно. Ввиду простоты и малого объема кода найти ошибку не представляет особого труда, но... Но в реальном приложении код, занимающийся приведением типов, может быть значительно удален от кода, манипулирующего объектом, получаемым в результате этого приведения. Что самое печальное, фрагментов кода, в которых происходит инициализация поля, может быть много. И уж совсем удручает то, что null в поле может появиться вследствие очень большого количества сценариев. Например, если в приведенном выше примере удалить вызов метода Init, результат будет тем же, так как переменные ссылочных типов по умолчанию инициализируются именно null-ом. Найти фактическое место ошибки в таких ситуациях бывает очень непросто.

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

Забавный пример продемонстрировал IT (Игорь Ткачев) в одном из сообщений на форумах www.rsdn.ru. Позволю себе его процитировать:

Не секрет, что отладка программ на C# в сравнении с C++ гораздо проще, и занимает в разы меньше времени. Тем не менее, для настоящего "джидая" это не является проблемой. Рассмотрим простой пример:

      // Интерфейс, который должны в обязательном порядке реализовывать 
      // все объекты, создаваемые фабрикой классов
      interface IMyInterface
{
  void Method();
}

// Некая фабрика классов, динамически создающая объект // по переданному в нее типуclass A
{
  IMyInterface _myInterface;

  publicvoid CreateInstance(Type type)
  {
    _myInterface = GetTypeFromConfigAndCreateInstance(type) as IMyInterface;
  }

  publicvoid CallMethod()
  {
    _myInterface.Method();
  }
}

// Код, использующий эту фабрику классовclass B
{
  void Foo(Type type)
  {
    A a = new A();

    a.CreateInstance(type);
    a.CallMethod();
  }
}

Предположим, что типы объектов хранятся в конфигурационных файлах. Это может быть собственная реализация конфигурации Remouting-а, плагины и т.д. Все типы, указанные в конфигурационном файле, обязаны поддерживать интерфейс IMyInterface. Если это не так, то это ошибка и тот, кто поместил в конфигурационный файл тип, не поддерживающий IMyInterface, должен это исправить. Таким образом, код, приведённый выше, казалось бы, стопроцентно корректен.

Теперь рассмотрим этот код с точки зрения того, кто его использует. Допустим, я где-то что-то упустил и забыл добавить поддержку IMyInterface в нужном мне классе. Вызов метода Foo даст исключение в методе A.CallMethod и, что самое печальное, это будет NullReferenceException, т.е. понятно, что что-то не то, но полезной информации ноль.

Естественно, в таком простом коде не составит труда быстро найти настоящую причину. Но представим, что этот класс занимает тысячу строк витиеватого кода, да ещё, может быть, это реализуется не один классом, это может быть целый фреймворк. Найти истинную причину в этом случае может оказаться не совсем просто.

Но всё-таки после нескольких бессонных ночей я выхожу на A.CreateInstance и выясняю, что проблема в моём собственном коде, и понимаю, что сам дурак. Далее, чтобы избежать длительных поисков подобных ошибок в будущем, я привожу за ухо того, кто писал этот код и прошу его заменить as на cast. Но его аргументы такие: ошибка не в его коде, она уже исправлена мной самим в моём коде, и, главное, он не знает к чему приведут такие изменения, т.к. писал он это давно и уже не помнит, завязана ли его логика на возможный null или нет. Всё остается, как есть, и впоследствии на эти грабли наступают другие разработчики, тратя на борьбу с ними много времени.

...

Проблема as vs cast не должна рассматриваться с точки зрения быстродействия, тиков процессора и пр. Это та же проблема, что и наличие Code Convention и Best Practices в команде. Следуя им ты не облегчаешь жизнь себе, ты облегчаешь жизнь другим.

Итак, что же общего во всех этих случаях?

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

При этом делается два очень опасных предположения:

Я все делаю верно. (Ведь я все проверил!)

Со временем не произойдет изменений, которые приведут к ошибке при приведении. (Ведь я-то точно знаю!)

А зря. Это в корне неверные предположения. Как говорит один мой знакомый, «если после компиляции объемистого, только что написанного кода компилятор не выдал ошибки, обратитесь к разработчикам компилятора, так как в нем баг.» :)

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

Использование штатного оператора приведения типов «()» не делает код правильнее автоматически и не защищает от ошибок. С точки зрения функциональности код останется точно таким же. Оператор () позволит локализовать ошибку в месте, наиболее близком к ее появлению. А это в свою очередь позволит сэкономить немало вашего (и не только) драгоценного времени.

Оператор as – это очень удобное средство. При грамотном применении он способен делать код более простым, быстрым и даже способен упростить поддержку ПО. Но все это будет, только если за as следует проверка на null (или не равенство null). В противном случае вы сами роете себе западню. Использование оператора as без последующей проверки маскирует истинную причину ошибки (и место, в котором она произошла), обрубая все концы. Те, кто провел несколько бессонных ночей, отлаживая ошибки, связанные с модификацией памяти по некорректным указателям в unmanaged-приложениях, поймут меня без лишних объяснений. И, хотя в случае C# проблему локализовать намного проще, все равно писать такой код можно, только если вы очень любите часами сидеть в отладчике, тупо перебирая возможные варианты ошибок.

Проблема «as» усиливается тем, что приведение через as со временем входит в привычку. Опять-таки не важно, по какой причине. Но практика показывает, что однажды применив as вместо оператора приведения типов, большинство программистов начинают лепить его где ни попадя. Причем со временем боязнь ошибки уходит, и as может появиться в совершенно немыслимых ситуациях.

Если же человек применяет as только там, где без него невозможно обойтись, то и проблем не возникает.

Основания для использования as вместо ()

В оправдание использования as вместо () выдвигается два аргумента:

  1. as порождает более быстрый код.
  2. as более эстетично, чем применение стандартного оператора приведения типов.

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

      class Test
{
  class A { }

  class B : A
  {
    publicvoid SomeMethod() { }
  }

  staticvoid Main()
  {
    B b = new B();
    CallSomeMethod(b);
  }

  staticvoid CallSomeMethod(A a)
  {
    ((B)a).SomeMethod();
  }
}

В принципе, лучше было бы ввести в C# приведение в функциональном стиле (как в Паскале или C++). Например, оно могло бы выглядеть так:

cast<B>(a).SomeMethod();

или просто:

B(a).SomeMethod();

Однако разработчики C# решили иначе.

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

Теперь о скорости. В Framework 1.0 и 1.1 скорость операторов была следующей: самым быстрым был оператор is, за ним шел as и последним шел оператор приведения типов (). Причем is и as отличались очень незначительно, а () был медленнее приблизительно на 10-20%. Даже применение as с последующей проверкой на null было заметно быстрее, чем оператор приведения типа.

Вот простенький тест, приблизительно измеряющий скорость работы операторов as и ():

      static
      void Simpletest()
{
  A a = new B();
  PerfCounter timer = new PerfCounter();

  ////////////////////////////////////////////////////////////////////// Приведение типов с помощью Virtual call
  timer.Start();

  for (int i = 0; i < IterCount; i++)
    _b = a.GetB();

  Console.WriteLine("Время приведения через виртуальный метод: {0}", 
    timer.Finish());

  ////////////////////////////////////////////////////////////////////// Приведение типов с помощью оператора as
  timer.Start();

  for (int i = 0; i < IterCount; i++)
    _b = a as B;

  Console.WriteLine("Время приведения через as: {0}", timer.Finish());

  ////////////////////////////////////////////////////////////////////// Приведение типов с помощью оператора ()
  timer.Start();

  for (int i = 0; i < IterCount; i++)
    _b = (B)a;

  Console.WriteLine("Время штатного приведения (): {0}", timer.Finish());
}

// Базовый класс.class A
{
  protectedint _i1;

  // Этот метод используется для сравнения вызова виртуального метода // с операторами приведения.publicvirtual B GetB() { returnnull; }
}


// Класс-наследник, с которым будут производиться манипуляции приведения // и клонирования.class B : A, ICloneable
{
  public B()
  {
    _array = newint[]
    {
      1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10,
      1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10,
      1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10,
      1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10,
      1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10,
    };

    _s1 = "Папа мама я...";
  }

  // Копирующий конструктор. Аналогично MemberwiseClone производит// поверхностное копирование объекта. public B(B b)
  {
    _array = b._array;
    _s1 = b._s1;
    _i1 = b._i1;
  }

  publicint[] _array;
  string _s1;

  publicoverride B GetB() { returnthis; }

  #region ICloneable Members
  publicobject Clone()
  {
    return MemberwiseClone();
  }
  #endregion
}

Результаты этого теста таковы:

Вид теста Время в секундах
Приведениe через виртуальный метод 0.108
Приведениe через as 0.108
Приведениe через () 0.140

Налицо явное преимущество. Но можно ли жертвовать надежностью ПО ради скорости? И каков выигрыш от подобного рода жертв? Ведь это чисто синтетические тесты. Они не учитывают, что операция приведения типов происходит очень быстро, и обязательно вкупе с кучей других, порой куда более медленных, операций. Те, кто пытается найти лишнее время таким образом, просто не там его ищут. Вот, например, как влияет приведение типов на операцию поверхностного клонирования объектов. Вот код теста (код классов A и B можно найти в предыдущем тесте):

      static
      void CloneTest()
{
  B b = new B();
  PerfCounter timer = new PerfCounter();

  ///////////////////////////////////////////////////////////////////// Клонирование через копирующий конструктор
  timer.Start();

  for (int i = 0; i < IterCount; i++)
    _b = new B(b);

  Console.WriteLine("Время клонирования через копирующий конструктор: {0}",
    timer.Finish());

  ///////////////////////////////////////////////////////////////////// Клонирование с приведением через as
  timer.Start();

  for (int i = 0; i < IterCount; i++)
  {
    _b = b.Clone() as B;
    //_b._array[0] = 123;
  }

  Console.WriteLine("Время клонирования с приведением через as: {0}",
    timer.Finish());

  ///////////////////////////////////////////////////////////////////// Клонирование с приведением через ()
  timer.Start();

  for (int i = 0; i < IterCount; i++)
    _b = (B)b.Clone();

  Console.WriteLine("Время клонирования с приведением через (): {0}",
    timer.Finish());
}

А вот его результаты:

Тест Время в секундах
Клонирование методом MemberwiseClone с приведением через as 1.572
Клонирование методом MemberwiseClone с приведением через () 1.656
Клонирование через копирующий конструктор 0.403

Первые два теста – это поверхностное копирование методом MemberwiseClone с последующим приведением типа ссылки на объект к нужному. Последний – это поверхностное копирование с помощью копирующего конструктора, осуществляющего ручное, поэлементное копирование полей объекта.

Как видите, разница между применением оператора as и () не превышает 10%, в то время как ручная реализация клонирования дает очень резкий скачок производительности (более, чем в 4 раза). И это к тому же очень быстрый алгоритм, на фоне которого применение оператора приведения хорошо заметно. На алгоритмах, использующих процессор более интенсивно, приведение вообще становится незаметным. В сущности, в данном случае выбор между as и () – это ловля блох вместо использования шампуня.

Самое смешное, что все эти «выигрыши» в скорости основываются на конкретной реализации обсуждаемых операторов. В принципе все может быть совершенно по-другому. Другими словами, «гонщики» опираются на особенности реализации текущей версии, которые могут легко измениться в будущем. Забавно, что подобную мысль я изложил в обсуждении оператора as на форумах RSDN, и через пару дней узнал о том, что в .Net Framework 2.0 (первой официальной бета-версии) положение вещей изменилось, причем изменилось радикально. Приведенный выше пример продемонстрировал практически одинаковое время работы операторов as и (). Вот результаты чисто синтетического теста:

Вид теста Время в секундах
Приведениe через виртуальный метод 0.124
Приведениe через as 0.101
Приведениe через () 0.108

А вот результаты теста клонирования:

Тест Время в секундах
Клонирование методом MemberwiseClone с приведением через as 2.294
Клонирование методом MemberwiseClone с приведением через () 2.367
Клонирование через копирующий конструктор 0.341

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

Цена ошибки

Одним из аргументов в пользу применения оператора as вместо приведения типов, являются слова «в этом случае я могу доказать, что приведение всегда будет проходить успешно». Все мы люди и можем ошибиться, не учитывать каких-то еще не известных в данный момент нюансов, да и попросту могут произойти какие-то изменения. Ниже приведена цитата из рассказа «Катастрофа Ariane 5», повествующего о том, как от одного предположения (причем просчитанного разными умными способами), люди потеряли полмиллиарда долларов (полностью его можно найти по адресу http://www.osp.ru/os/1998/06/21.htm#part_1):

Расследование показало, что в данном программном модуле присутствовало целых семь переменных, вовлеченных в операции преобразования типов. Оказалось, что разработчики проводили анализ всех операций, способных потенциально генерировать исключение, на уязвимость. И это было их вполне сознательным решением – добавить надлежащую защиту к четырем переменным, а три – включая BH – оставить незащищенными. Основанием для такого решения была уверенность в том, что для этих трех переменных возникновение ситуации переполнения невозможно в принципе. Уверенность эта была подкреплена расчетами, показывающими, что ожидаемый диапазон физических полетных параметров, на основании которых определяются величины упомянутых переменных, таков, что к нежелательной ситуации привести не может. И это было верно – но для траектории, рассчитанной для модели Ariane 4. А ракета нового поколения Ariane 5 стартовала по совсем другой траектории, для которой никаких оценок не выполнялось. Между тем она (вкупе с высоким начальным ускорением) была такова, что "горизонтальная скорость" превзошла расчетную (для Ariane 4) более чем в пять раз.

Таким образом даже самые продуманные и ответственные предположения могут оказаться неверными по недомыслию или вследствие изменения обстоятельств. А цена ошибки заранее никогда неизвестна. В лучшем случае вы обрекаете себя или кого-то другого на несколько часов виртуального секса с компьютером. :) А в худшем – подставляете фирму, в которой работаете, на деньги, и, возможно, на немалые деньги.

Дополнительные аргументы в пользу ()

Есть люди, ставящие эстетические ценности значительно выше банальной логики. Для них хочется привести эстетический аргумент. :) Не знаю как для кого, но для меня очень важно, чтобы одинаковый по смыслу код выглядел бы одинаково. Иными словами, я ценю в коде единообразие. Это позволяет облегчить восприятие кода, доведя его до практически подсознательного уровня. Так вот as нельзя использовать для value-типов, а значит, работа с value-типами будет резко контрастировать с работой со ссылочными типами. В коде то и дело будут встречаться идентичные фрагменты, написанные в разных стилях. Применение же штатного приведения делает код единообразнее, а значит более простым и понятным.


Эта статья опубликована в журнале RSDN Magazine #3-2004. Информацию о журнале можно найти здесь
    Сообщений 88    Оценка 1078 [+8/-2]         Оценить