Язык программирования Си#: критическая оценка

С. Свердлов

 

В июне 2000 года стало известно о новом языке программирования, родившемся в недрах компании Microsoft. Он стал частью новой технологии Microsoft, названной .NET (читается «Dot Net»). В рамках этой технологии предусмотрена единая среда выполнения программ (Common Language Runtime, CLR), написанных на разных языках программирования. Одним из таких языков, основным в этой среде, и является Си# (C#, читается «C sharp», «Си шарп»). Названием языка, конечно же, хотели подчеркнуть его родство с Си++, ведь # — это два пересекшихся плюса[1]. Но больше всего новый язык похож на Яву. И нет сомнений, что одной из причин его появления стало стремление Microsoft ответить на вызов компании Sun.

Хотя официально авторы Си# не называются, но на титульном листе одной из предварительных редакций справочника по языку обозначены Андерс Хейльсберг (Anders Hejlsberg) — создатель Турбо Паскаля и Дельфи, перешедший в 1996 году в Microsoft, и Скотт Вилтамут (Scott Wiltamuth).

Единая среда выполнения программ основана на использовании промежуточного языка IL (Intermediate Language — промежуточный язык)[2], исполняющего почти ту же роль, что и байт-код виртуальной машины языка Ява. Используемые в рамках технологии .NET компиляторы с различных языков транслируют программы в IL-код. Так же как и байт-код Явы, IL-код представляет собой команды гипотетической стековой вычислительной машины. Но есть и разница в устройстве и использовании IL.

Во-первых, в отличие от JVM, IL не привязан к одному языку программирования. В составе, предварительных версий Microsoft.NET имеются компиляторы с языков Си++, Си#, Visual Basic. Независимые разработчики могут добавлять другие языки, создавая компиляторы с этих языков в IL-код.

Во-вторых, IL предназначен не для программной интерпретации, а для последующей компиляции в машинный код. Это позволяет достичь существенно большего быстродействия программ. Содержащие IL-код файлы несут достаточно информации для работы оптимизирующего компилятора.

Основные черты Си#

«Си# — простой, современный, объектно-ориентированный язык с безопасной системой типов, происходящий от Си и Си++. Си# будет удобен и понятен для программистов, знающих Си и Си++. Си# сочетает продуктивность Visual Basic и мощность Си++.» Такими словами начинается описание Си#. Мы же рассмотрим технические особенности языка.

l        Единицей компиляции является файл (как в Си, Си++, Яве). Файл может содержать одно или несколько описаний типов: классов (class), интерфейсов (interface), структур (struct), перечислений (enum), типов-делегатов (delegate) с указанием (или без указания) об их распределении по пространствам имен. 

l        Пространства имен (namespace) регулируют видимость объектов программы (как в Си++). Пространства имен могут быть вложенными. Разрешено употребление объектов программы без явного указания пространства имен, которому этот объект принадлежит. Достаточно лишь общего упоминания об использовании этого пространства имен в директиве using (как в Турбо Паскале). Предусмотрены псевдонимы для названий пространств имен в директиве using (как в языке Оберон).

l        Элементарные типы данных: 8-разрядные  (sbyte, byte), 16-разрядные (short, ushort), 32-разрядные (int, uint) и 64-разрядные (long, ulong) целые со знаком и без знака, вещественные одиночной (float) и двойной (double) точности, символы Unicode (char), логический тип (bool, не совместим с целыми), десятичный тип, обеспечивающий точность 28 значащих цифр (decimal).

l        Структурированные типы: классы и интерфейсы (как в Яве), одномерные и многомерные (в отличие от Явы) массивы, строки (string), структуры (почти то же, что и классы, но размещаемые не куче и без наследования), перечисления, несовместимые с целыми (как в Паскале).

l        Типы-делегаты или просто «делегаты» (подобны процедурным типам в Модуле‑2 и Обероне, указателям на функции в Си и Си++).

l        Типы подразделяются на ссылочные (классы, интерфейсы, массивы, делегаты) и типы-значения (элементарные типы, перечисления, структуры). Объекты ссылочных типов размещаются в динамической памяти (куче), а переменные ссылочных типов являются, по сути, указателями на эти объекты. В случае типов-значений переменные представляют собой не указатели, а сами значения. Неявные преобразования типов разрешены только для случаев, когда они не нарушают систему безопасности типов и не приводят к потере информации. Все типы, включая элементарные, совместимы с типом object, который является базовым классом всех прочих типов. Предусмотрено неявное преобразование типов-значений к типу object, называемое упаковкой (boxing), и явное обратное преобразование — распаковка (unboxing).

l        Автоматическая сборка мусора (как в Обероне и Яве).

l        Обширный набор операций с 14 уровнями приоритета. Переопределение операций (как в Алголе-68, Аде, Си++). С помощью операторов checked и unchecked можно управлять контролем переполнения при выполнении операций с целыми.

l        Методы с параметрами значениями, параметрами-ссылками (ref) и выходными параметрами (out). Слова ref и out нужно записывать перед параметром не только в описании метода, но и при вызове. Наличие выходных параметров позволяет контролировать выполнение определяющих присваиваний. По правилам языка любая переменная должна гарантированно получить значение до того, как будет предпринята попытка ее использования.

l        Управляющие операторы: if, switch, while, do, for, break, continue (как в Си, Си++ и Яве). Оператор foreach, выполняющий цикл для каждого элемента «коллекции», несколько разновидностей оператора перехода goto.

l        Обработка исключений (как в Яве).

l        Свойства — элементы классов (объектов), доступ к которым осуществляется так же, как и к полям (можно присвоить или получить значение), но реализуется неявно вызываемыми подпрограммами get и set (как в Объектном Паскале — входном языке системы Delphi).

l        Индексаторы — элементы классов (объектов), позволяющие обращаться к объектам так же, как к массивам (указанием индекса в квадратных скобках). Реализуются неявно вызываемыми подпрограммами get и set[3]. Например, доступ (для чтения) к символам строки может выполняться как к элементам массива благодаря тому, что для стандартного класса string реализован индексатор.

l        События — элементы классов (поля или свойства) процедурного типа (делегаты), к которым вне класса, где они определены, применимы только операции += и –=, позволяющие добавить или удалить методы-обработчики событий для объектов данного класса.

l        Небезопасный (unsafe) код, использующий указатели и адресную арифметику, локализуется в частях программы, помеченных модификатором unsafe.

l        Препроцессор, предусматривающий, в отличие от Си и Си++, только средства условной компиляции.

Примеры программ на Си#

Рассмотрим вначале простейшую законченную программу, процесс ее компиляции и выполнения. Разместим текст программы в файле Hello.cs:

/* Простейшая программа на языке Си# */

class Hello {

static void Main() {

System.Console.WriteLine("Hello, World!");

}

}

Для компиляции программы можно воспользоваться компилятором csc, который входит в состав Microsoft .NET Framework SDK — комплект разработчика для среды Microsoft .NET и запускается из командной строки:

csc Hello.cs

После компиляции будет получен исполнимый файл Hello.exe. Но запустить его на компьютере, работающим под управлением ОС Windows, можно, только если на этом компьютере установлена поддержка Microsoft .NET. Дело в том, что полученный после компиляции файл (несмотря на свое название) содержит не обычные машинные команды, а IL-код, который будет преобразован в код процессора при загрузке и запуске программы.

Но, если .NET Framework SDK установлен, значит, соответствующая поддержка имеется. Запустив Hello.exe, получим:

Hello.exe

Hello, World!

Теперь обратимся к тексту программы. В ней определен единственный класс Hello, в котором содержится описание статического метода (метода класса) Main. Статический метод с названием Main (большие и малые буквы в Си# различаются) является точкой входа в программу, написанную на Си#. С выполнения этого метода начинается работа программы. В отличие от языка Ява, метод Main в Си# может быть без параметров или с параметрами, не важно также, возвращает ли он значение (являясь функцией) или нет. В нашем примере Main не имеет ни параметров, ни возвращаемого значения (void).

Единственный оператор в методе Main — вызов статического метода WriteLine. Это метод класса Console, предоставляющего доступ к стандартным выходному и входному потокам. Класс Console принадлежит (предопределенному) пространству имен System.

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

/* Простейшая программа на языке Си# */

using System; // разрешается неквалифицированный доступ

class Hello {

static void Main() {

Console.WriteLine("Hello, World!");

}

}

Сортировка на Си#: найдите отличия

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

// Сортировка простыми вставками на Си#

public static void InsSort( float[] a ) {

for (int i = 1; i<a.Length; i++) {

    float x = a[i];

    if ( x<a[i-1] ) {

    a[i] = a[i-1];

    int j = i-2;

    while (j>=0 && x<a[j])

        a[j+1] = a[j--];

    a[j+1] = x;

    }

}

}

Отличия одно. Слово «длина» пишется с большой буквы: Length (в Яве — length). Length — это свойство (property) стандартного класса System.Array, который является родоначальником массивов в Си#.

Первые впечатления

К моменту написания этих строк существуют лишь предварительное описание языка Си# и предварительная версия средств разработки программ на этом языке. Поэтому делать какие-либо обобщающие выводы еще рано. Но некоторые суждения высказать можно.

В Си# сохранены и даже приведены в порядок некоторые традиционные конструкции: перечисления, структуры, многомерные массивы. Ява в этом отношении демонстрирует более экстремистский подход: объекты и ничего кроме объектов. Устранены явные несуразности Явы, вроде отсутствия передачи параметров по ссылке при отсутствии же указателей. Механизм передачи параметров в Си# хорошо продуман, предусматривая передачу, как по значению, так и по ссылке.

Вместе с тем, в Си# есть много конструкций, которые, как минимум, могут вызывать вопросы. Язык избыточен и, как следствие, сложен в использовании и реализации. Некоторые средства Си# могут провоцировать ошибки. Сомнения по поводу Си# становятся особенно сильны при его сравнении с языками, созданными Н. Виртом, в первую очередь с Обероном.

Пространства имен

В Си#, как и в других языках, происходящих от Си, так и не получила воплощения простая и ясная концепция модуля. Вместо этого использованы пространства имен — средство, появившееся на поздних стадиях стандартизации Си++. Пространства имен — это весьма общий механизм, поглощающий, в частности, и возможности, предоставляемые модулями. Но здесь налицо чрезмерное обобщение[4], не обусловленное насущными потребностями, предоставляющее программисту избыточные средства, а с ними и возможности для злоупотреблений. Вложенность пространств имен, их длинные составные обозначения служат препятствием к тому, чтобы потребовать обязательного явного (квалифицированного) использования имен, взятых из этих пространств[5], как это сделано в Обероне для импортируемых модулем идентификаторов. Неявный же импорт, разрешаемый директивой using[6], — источник ошибок, связанных с коллизией имен. Вот тому пример.

Рассмотрим программу, в которой определено пространство имен Hello, а внутри этого пространства имен — вложенные друг в друга классы A и B. Класс B содержит единственное статическое поле C, которое проинициализировано значением «Привет!».

// Эта программа хранится в файле Hello.cs

namespace Hello {

public class A {

public class B {

    public static string C = "Привет!";

    }

}

}

Содержимое файла Hello.cs не является независимой программой, но может быть отдельной единицей компиляции, оттранслировав которую можно получить динамически компонуемую библиотеку[7] (файл с расширением dll). Для этого при запуске компилятора csc нужно использовать параметр /target:

csc /target:library Hello.cs

В результате компиляции будет получена библиотека Hello.dll.

Теперь напишем основную программу, которая сможет воспользоваться ресурсами нашей библиотеки. А ресурс, собственно, один — строка, содержащая «Привет!». Ее и напечатаем:

// Эта программа хранится в файле Print.cs

class Print {

static void Main() {

System.Console.WriteLine(Hello.A.B.C);

}

}

Поместим эту программу в файл Print.cs и откомпилируем ее. Чтобы при компиляции Print.cs была доступна библиотека Hello.dll, упомянем ее в команде, вызывающей компилятор, с помощью параметра /reference:

csc /reference:Hello.dll Print.cs

В результате компиляции получается исполнимый файл Print.exe, который можно запустить и увидеть напечатанное слово «Привет!»:

Print.exe

Привет!

Теперь модифицируем программу Print.cs, воспользовавшись директивами using для указания пространств имен System и Hello, из которых импортируются  нашей программой классы Console и A:

using System;

using Hello;

class Print {

static void Main() {

    // Console – из пространства имен System;

    // A – из пространства имен Hello.

    Console.WriteLine(A.B.C);

}

}

Компилируем заново Print.cs, запускаем, получаем тот же результат (а как же иначе):

csc /reference:Hello.dll Print.cs

Print.exe

Привет!

Теперь, ничего не меняя в уже написанном коде Print.cs и Hello.cs, подключаем к трансляции Print.cs еще одну библиотеку (A.dll). В реальной задаче это могло потребоваться, когда программе Print стали нужны какие-то средства, имеющиеся в библиотеке A.dll. Компилируем и запускаем Print:

csc /reference:Hello.dll,A.dll Print.cs

Print.exe

2.7182818284590451

Но что это? Вместо "Привета" (ведь мы ничего не меняли в программе, по-прежнему при компиляции упомянули библиотеку Hello.dll, которая оставалась на том же месте) выведено какое-то число[8]!

Дело в том, что, к нашему несчастью, во вновь подключенной библиотеке оказалось определено пространство имен A, а в нем класс B, а в нем — доступное статическое поле C. В реальной ситуации в библиотеке A.dll могли быть определены также и другие классы и пространства имен. В нашем же примере A.dll была получена компиляцией файла A.cs:

// Эта программа хранится в файле A.cs

namespace A {

public class B {

public static double C = 2.71828182845904523;

}

}

Теперь в операторе Console.WriteLine(A.B.C); программы Print идентификатор A воспринимается как обозначающий пространство имен A, а не класс A пространства имен Hello! Язык Си# подвел нас. Причем, дважды. В первый раз, когда была допущена коллизия имени класса A пространства имен Hello и названия пространства имен A. Эта коллизия была почему-то разрешена в пользу названия пространства имен, в то время как директива using Hello создала в пределах своего действия локальную область, в которой локальные имена должны были бы иметь преимущество. Во-вторых, возникшее несоответствие не было обнаружено даже при том, что два разных поля с именем С были разных типов. Если бы метод WriteLine[9] не был столь либерален к типу своих параметров, был шанс обнаружить ошибку.

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

В чем причина таких прорех и как они могли бы быть устранены? Дело в том, что в отношении названий пространств имен в Си# не действует общее, признанное еще со времен Алгола-60, правило, согласно которому любой идентификатор в программе не может быть использован без (предварительного) описания. Для исключения рассмотренных коллизий необходимо, чтобы директивы using были обязательными, вместе с обязательной квалификацией идентификаторов названием пространства имен. То есть, следовало бы потребовать, чтобы программа Print могла быть записана только в таком виде:

// Безопасное использование пространств имен

using System; // Описание названия пространства имен

using Hello;  // Описание названия пространства имен

class Print {

static void Main() {

    // Использование названий пространств имен:

    System.Console.WriteLine(Hello.A.B.C);

}

}

Си# подобную запись разрешает, но, увы, не требует. А вот в языке Оберон импорт организован именно таким образом, и возникновение рассмотренных проблем исключено.

Пространство программы

В Си#, как и в языке Ява вне определений классов (а также интерфейсов и структур) нельзя размещать ни описания полей, ни описания методов. Это довольно странное правило, особенно для такого языка, как Си#, в котором границы пространств имен оформляются явно — скобками.

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

Во-первых, отпадает необходимость в описателе static, который загромождает текст программы. Поля, описанные вне классов, будут считаться статическими. Это очень естественно еще и вот по какой причине. При существующей в Си# (и Яве) ситуации неэффективно используется само пространство текста программы. Принадлежность поля или метода определяется не его местоположением в тексте, а наличием или отсутствием описателя static.

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

Наконец, класс перестает противоестественно совмещать две различные роли — описания типа и пространства для статических полей и методов. Такое совмещение, кстати, затрудняет понимание и изучение языков Ява и Си#.

Рассмотрим перечисленные возможности на уже обсуждавшемся примере. Пространство имен А, содержащее (статическое) поле С, можно было бы определить так:

// Это программа на модифицированном Си#

namespace A {

public double C = 2.71828182845904523;

}

Класс B для определения поля C больше не нужен. Устраним класс B и в программе из файла Hello:

// Это программа на модифицированном Си#

namespace Hello {

namespace A {

public string C = "Привет!";

}

}

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

Вот как могла бы выглядеть программа Print, которая печатает значения обоих полей, названных C:

// Безопасное использование пространств имен
// в модернизированном Си#

using Cons=System.Console;

using HA=Hello.A;

using A;

namespace Print {

void Main() {

Cons.WriteLine(HA.C);

Cons.WriteLine(A.C);

}

}

Прежний класс Print превратился в пространство имен, поскольку классы теперь не являются контейнерами для статических методов, в данном случае, для метода Main. Использованы директивы using, определяющие псевдонимы (Cons и HA) для импортируемых пространств имен. Такая форма using предусмотрена в Си#. Стандартный класс Console пространства имен System теперь следует считать (вложенным в System) пространством имен, поскольку он содержит только статические поля и методы (кроме унаследованных от класса Object).

В результате мы пришли к простой и безопасной конструкции, которая совпадает с тем, что уже давно имеется в языке Оберон. Только модули в Обероне, в отличие от пространств имен Си#, не бывают вложенными.

Избыточность

Некоторые средства языка Си# являются избыточными. Избыточными в том смысле, что не добавляют языку каких-либо функциональных возможностей, а лишь позволяют в иной форме записать то, что и так может быть выражено достаточно просто. К числу таких средств можно отнести свойства (properties) и индексаторы (indexers).

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

Пусть строковое (типа string) свойство Title экранных объектов класса Element задает надпись-заголовок таких объектов. Объявление этого свойства может выглядеть так:

public class Element {

string title;                // Это поле

public string Title {      // А это - свойство

    get { return title;}

    set {

    title = value;

    Repaint();          // Перерисовать

    }

}

// Далее определяются другие поля и методы

¼

}

Сама надпись хранится в поле title, а свойство Title организует доступ к нему c помощью подпрограмм get (получить) и set (установить). Если e — переменная, обозначающая объект класса Element, то для изменения надписи и перерисовки объекта на экране достаточно записать:

e.Title = "Привет";

Такое присваивание приводит к выполнению подпрограммы set. А, например, при выполнении оператора, добавляющего к заголовку восклицательный знак:

e.Title += "!";

неявно вызываются и get и set.

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

public class Element {

string title;

public string getTitle() {

return title;

}

public void setTitle( string value ) {

    title = value;

Repaint();   // Перерисовать

}

// Далее определяются другие поля и методы

¼

}

В этом случае вместо e.Title = "Привет"; нужно записать

e.setTitle("Привет");

а взамен e.Title += "!"; необходимо использовать

e.setTitle(e.getTitle()+"!");

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

Программирование в среде визуальных систем, подобных Delphi, откуда в язык Си# перекочевали свойства, можно разделить на несколько уровней. Первый — разработка прикладных программ. В этом случае программист, создавая программу с графическим интерфейсом пользователя, как правило, пользуется лишь готовыми компонентами с уже запрограммированными свойствами. Внешняя неотличимость свойств и полей в этом случае не слишком мешает, поскольку речь идет об использовании уже отлаженных библиотек, а в распоряжении программиста есть встроенная система помощи. Более того, многие свойства визуальных компонент даже не фигурируют в той части программы, которую программист пишет вручную. Значения свойств окон, кнопок и других элементов интерфейса просто задаются в режиме диалога с визуальной системой. Прикладной программист в визуальной среде, по сути, имеет дело не со всем языком программирования, а лишь с той его частью, в которую входит возможность использования готовых свойств готовых классов, но не входит их определение.

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

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

Весьма неоднозначно можно оценить конструкцию Си#, называемую  индексатором. Индексаторы — это элементы классов (а также интерфейсов и структур), позволяющие обращаться к объектам как к массивам (указанием индекса в квадратных скобках). Реализуется такой доступ неявно вызываемыми подпрограммами get и set.

Например, обращение к отдельным битам 32-разрядного целого значения (bits) можно замаскировать под обращение к логическому массиву. Для этого создается такое описание (в данном случае структуры):

public struct BitSet {

int bits;                 // Поле целого типа

public bool this[int i] { // Индексатор

get{return 0<=i && i<32? (bits & 1<<i) != 0: false;}

    set {

    if(i<0 || i>31) return;

    if(value) bits |= 1<<i; else bits &= ~(1<<i);

    }

}

}

Теперь использование переменной типа BitSet может выглядеть, например, так:

BitSet b = new BitSet(); // Описание переменной

¼

// Все биты устанавливаются в единичное значение

for (int i=0; i<32; i++ )

    b[i] = true;

¼

if(b[i]) ¼   // Проверка i-го бита

¼

В описании типа BitSet использовано много специфических обозначений. && — условное «И»; & — поразрядное логическое «И»; << — сдвиг влево; != — не равно; ¼ ? ¼ : ¼ — условная операция — если истинно логическое выражение перед «?», то результат операции вычисляется по выражению, записанному перед двоеточием, иначе — по записанному после двоеточия; || — поразрядное логическое «ИЛИ»; |= — присваивание с поразрядным «ИЛИ» (обратите внимание на схожесть с !=); &= — присваивание с поразрядным «И»; ~ — поразрядное «НЕ». Идентификатор value представляет значение, переданное подпрограмме set.

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

public struct BitSet {

int bits;

public bool get(int i) { // Получить

return 0<=i && i<32? (bits & 1<<i) != 0: false;

}

public void set(int i, bool value) { // Установить

if(i<0 || i>31) return;

if(value) bits |= 1<<i; else bits &= ~(1<<i);

}

}

Обратите внимание, что содержание подпрограмм get и set нисколько не изменилось. Только теперь это — обычные методы. Для доступа к отдельным битам квадратные скобки уже не применить:

BitSet b = new BitSet(); // Описание переменной

¼

// Все биты устанавливаются в единичное значение

for (int i=0; i<32; i++ )

b.set(i, true);

¼

if(b.get(i)) ¼ // Проверка i-го бита

¼

По-видимому, одной из причин появления индексаторов в Си# было желание создателей языка естественным образом оформить обращение к символам строк, которые в Си# являются объектами, но не массивами. Если s — строка (string), то, только благодаря наличию индексатора в классе string,  i-й символ строки s можно обозначить s[i]. В языке Ява, где строки — тоже объекты, к отдельному символу приходится обращаться, вызвав специальный метод: s.charAt(i).

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

Преимущество обнаруживается одно:

·      Упрощение нотации. Действия по доступу к объекту и его изменению записываются как обращение к полю в случае свойств, и обращение к элементу массива при использовании индексаторов.

Недостатков можно назвать больше:

·        Усложняются язык и компилятор. С появлением свойств и индексаторов возникает много новых правил. В справочнике по Си# свойствам посвящено целых 8 страниц.

·      При использовании свойств и индексаторов от программиста скрываются затраты, которые происходят при работе подпрограмм доступа get и set, что провоцирует к употреблению неадекватных и неэффективных приемов. Например, использование индексатора для доступа к элементам линейного списка по их номеру[11] при значительной длине списка намного менее эффектно, чем обращение к элементу массива, хотя и выглядит так же. Применение такого индексатора для последовательного просмотра списка и вовсе абсурд, к которому, тем не менее, вас подталкивают.

·      Ухудшаются возможности понимания программы. По ее тексту становится невозможно узнать, имеем ли мы дело с полем или свойством, массивом или индексатором. Это при том, что обращение к полю всегда связано лишь с получением или установкой его значения, а обращение к свойству может предусматривать выполнение любых действий. Аналогично обстоит дело в случае массивов и индексаторов.

Приведу пример на языке Си#, когда простейший фрагмент, работа которого в иных обстоятельствах понималась бы абсолютно однозначно, при использовании Си# совершенно непредсказуема.

int i, s=0;

for (i=1; i<=100; i++ ) a[i]=i;

for (i=1; i<=100; i++ ) s += a[i];

System.Console.WriteLine(s);

Ну и что тут такого? Вначале элементам a поочередно присваиваются значения первых ста чисел натурального ряда 1, 2, 3, ¼, 99, 100. Затем вычисляется и выводится сумма этих чисел, которая обязана быть равна 5050. Ничего подобного! Напечатанное этой программой значение может оказаться каким угодно. Например, равным 338350, если a — это индексируемый объект такого типа:

class Array {

public int this[int i]{

    get{ return i*i; } // i-е значение равно i*i

    set {}             // Никаких действий

}

}

Побочный эффект

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

Рассмотрим пример. Интерпретаторы при вычислении выражений часто используют стек. Пусть Pop(S) — функция, возвращающая значение, извлекаемое из стека S, а Push(S, V) — процедура, помещающая значение V в стек S. При вызове Pop(S) стек меняется, эта функция обладает побочным эффектом. Для замены двух верхних значений в стеке их разностью (от значения, находящегося под вершиной надо отнять значение, расположенное на вершине) можно попробовать записать Push(S, –Pop(S)+Pop(S)). Программист при этом рассчитывает, что первый из двух записанных вызовов Pop(S) и выполнен будет первым. При этом значение, взятое с вершины стека,  будет участвовать в вычислении со знаком минус. На самом деле, если язык не устанавливает порядка вычисления операндов (так обстоит дело, например, в Паскале и Си), компилятор может поменять местами слагаемые и запрограммировать это действие как Push(S, Pop(S)–Pop(S))[12], что приведет к неверному результату.

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

Вместе с тем, в таких языках, как Алгол-68 и Си эксплуатация побочного эффекта — один из основных приемов получения компактной записи программы. В Си и происходящих от него языках побочный эффект создают операции увеличения и уменьшения (++, ––), а также использование операторов (в частности, присваивания) в роли выражений. Наличие этих возможностей является одной из основных предпосылок получения запутанных и малопонятных программ.

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

Обратимся к примеру. Приведенный ниже цикл достаточно типичен для программ на Си.

while( b=a[n++] ) { ¼ };

Выражение, записанное в круглых скобках, обладает в Си двойным побочным эффектом. Во-первых, каждое его вычисление присваивает переменной b значение n‑го элемента массива a, во-вторых, увеличивает значение переменной n. При отсутствии привычки к стилю языка Си понять такую конструкцию непросто, но возможно. Такая же запись допустима и в программе на Си#. Но, глядя на нее, уже нельзя сказать, что происходит. Ведь a может быть индексируемым объектом, а b — свойством, и «внутри» как одного, так и другого может быть что угодно.

Тяжеловесность

Такие конструкции языка Си#, как пространства имен, свойства, индексаторы приводят к ситуациям, когда по тексту программы (программной единицы) бывает невозможно понять природу используемых в программе объектов. Имена классов могут быть приняты за названия пространств имен и наоборот, свойства неотличимы от полей, индексируемые объекты — от массивов. При употреблении директивы using возникает неоднозначность определения принадлежности идентификаторов тому или иному пространству имен.

Отмеченные проблемы с однозначной идентификацией объектов программы могут быть частично решены с помощью встроенной в среду программирования системы подсказок. Язык Си# как раз и предназначается в первую очередь для использования в составе мощной среды программирования Microsoft Visual Studio. Она оснащена развитой системой помощи и средствами, позволяющими в ходе диалога с системой определить характеристики и принадлежность объектов программы.

Сказанное, означает, что язык Си# предполагает «тяжеловесную» реализацию, когда в составе системы программирования должны быть сложные вспомогательные инструменты, без которых разработка программ на Cи# осложняется. Значительные затраты на создание систем программирования для языка Си#, кроме достаточно высокой сложности самого языка, обусловлены и тем, что неотъемлемой его частью является обширная системная библиотека (пространство имен System).

Читать или писать

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

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

Как же соотносятся легкость чтения и написания программы в языке Си#, который происходит от Си и Си++? Большинство средств компактной записи, имевшиеся в Си и Си++, сохранены. Кое-что упрощено. Не применяются, например, такие обозначения как –>  и ::. Требование использовать лишь логические выражения в роли условий в операторах if и while делает практически бесполезной запись присваивания с его побочным эффектом в таком условии. Но в целом, возможности для побочных эффектов даже расширены (свойства, индексаторы). Переопределение операций[13] и совместное использование методов (имя метода не определяет его однозначно), делая запись более компактной и внешне простой, ухудшают возможности однозначного понимания программы. Неявный импорт с помощью using позволяет не выписывать длинные составные обозначения, но создает опасные коллизии имен.

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

class verbosity {

// Константа @const

protected internal const verbosity @const = null;

// Поле field

protected internal static readonly verbosity field

        = new verbosity();

// Метод method

protected internal virtual verbosity method()

    { return null; }

// Свойство property

protected internal static verbosity property

        { set{} }

// Индексатор

protected internal virtual verbosity this[int i]

        { set{} }

}

Она перегружена прилагательными. Конечно, описание класса verbosity умышленно сделано таким многословным (verbosity — многословие). Но язык это позволяет. Синтаксис Си# устроен так, что использование многочисленных модификаторов и описателей является нормой. Их следование друг за другом без всяких разделителей затрудняет восприятие программы.

Поясню использованные в примере обозначения. Все определенные элементы класса имеют тип verbosity. Модификаторы protected internal означают, что доступ к элементам класса ограничен пределами данного проекта (internal) или классов, производных от verbosity. readonly означает доступ только для чтения; virtual — возможность переопределения метода в производных классах. Символ @ в имени константы позволяет использовать зарезервированное слово const в роли идентификатора. Слово this, обозначая данный экземпляр класса, является обязательным элементом описания индексатора.

В то же время отсутствие в языке Си# специальных слов, обозначающих метод и свойство (подобно словам procedure, function в паскалеподобных языках) заставляет отличать их описания друг от друга и от описания полей и индексаторов по косвенным признакам. В описании метода после его имени есть круглые скобки; в описании свойства — фигурные; у индексатора — квадратные; в описании поля нет скобок, но может присутствовать знак равенства¼ Просто тест на внимательность получается.

Многословие Си# (как, впрочем, и Явы) выглядит непривлекательно и стилистически ущербно. Заимствованные из Си правила позволяют очень компактно записывать выражения и операторы, используя разнообразные специальные знаки. В то же время объектные нововведения оформлены громоздко и, наоборот, игнорируют возможности знаков препинания. В итоге получается, что и писать трудно, и читать не легко.

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

Перспективы Си#

Разумеется, обсуждавшиеся недостатки Си# вовсе не лишают язык перспектив. Он во многих отношениях предпочтительней Си++. Общая неудовлетворенность языком Си++, признанием которой является само появление нового языка, является одной из основных предпосылок успеха Си#.

Сравнивая Си# с Явой, можно увидеть много общих черт. Правда, если Ява-системы многоплатформны, то реализация Си# существует пока только для операционной системы Windows и только одна. Но, несмотря на тяжеловесность, можно ожидать, что язык будет реализован и для других систем. Кроме того, сама платформа Microsoft .NET с единой средой выполнения программ может быть продвинута на альтернативные архитектуры, в первую очередь на UNIX-системы.

Си# представляется более реалистичным языком, чем Ява. В отличие от Явы, он самодостаточен. То есть на Си# можно написать любую программу, не прибегая к другим языкам. Это возможно благодаря наличию «небезопасных» блоков кода, которые открывают доступ непосредственно к аппаратуре. В языке Ява для доступа к средствам низкого уровня должны использоваться «родные методы» (native methods), которые необходимо программировать на других языках.

И, разумеется, перспективы Си# в первую очередь связаны с теми усилиями, которые, конечно же, приложит компания Microsoft для его продвижения. Можно не сомневаться.



[1] В первых дискуссиях о новом языке, возникших в русском Интернете, было предложение называть язык по-русски «Си-диез». Очень симпатично. Ведь си — это еще и название ноты, а диез — изменение ноты на полтона.

[2] Идея применения единого промежуточного языка для построения многоязыковой системы программирования не нова. Еще в 60-е годы такие системы на основе общего машинно-ориентированного языка АЛМО были созданы в СССР для многих типов машин.

[3] Стремление обобщенно оформить доступ к массиву, сделав его синтаксически неотличимым от обращения к функции можно найти в языке Ада, где для записи индексов используются круглые скобки. В случае с индексаторами Си# — наоборот, обращение к функции или процедуре маскируется под обращение к массиву.

[4] Стремление к обобщению всего и вся можно заметить, например, в Алголе-68. Судьба его печальна.

[5] Предпосылки для обязательной квалификации имен в Си#, тем не менее, есть — предусмотрены псевдонимы пространств имен, которые могут быть короче их полных обозначений. Но создатели языка, видимо, не рискнули потребовать обязательной квалификации, опасаясь перенапрячь программистов, привыкших к вольностям Си++.

[6] Наследие Турбо Паскаля версии 4.0. И по слову (в Турбо Паскале — uses), и по создаваемым проблемам, и, видимо, по автору (А. Хейльсберг).

[7] Компиляция и выполнение программ рассматриваемого примера производились с помощью компилятора Microsoft (R) Visual C# Compiler Version 7.00.9030 и единой языковой среды исполнения (CLR version 1.00.2204.21) под управлением ОС Windows 95.

[8] Вы узнали приближенное значение числа e — основания натуральных логарифмов?

[9] На самом деле здесь мы имеем дело не с одним методом WriteLine, а с совокупностью совместно используемых (перекрытых) методов с одинаковыми названиями, но разными типами параметров.

[10] Для языка Объектный Паскаль, который используется в Delphi, такое применение не слишком актуально. Это специфический язык конкретной системы визуального программирования. Однако, язык Си#, по-видимому, претендует на всеобщность, на существование не только в рамках конкретной среды.

[11] Именно этот пример иллюстрирует использование индексаторов в документации по Си#.

[12] Турбо Паскаль именно так и поступает.

[13] Переопределение операций имеется и в языке Ада.