*1

     _____

     *1 Окончание. Начало см. PC Week/RE, № 20/2001, с. 31.

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

Некоторые средства языка Си# являются избыточными - в том смысле, что не добавляют к языку никаких функциональных возможностей, а лишь позволяют в иной форме записать то, что и так может быть выражено достаточно просто. К числу таких средств относятся свойства (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, откуда в язык Си# перекочевали свойства, можно разделить на несколько уровней. Первый - разработка прикладных программ. В этом случае программист, создавая программу с графическим интерфейсом пользователя, как правило, оперирует лишь готовыми компонентами с уже запрограммированными свойствами. Внешняя неотличимость свойств и полей в этом случае не слишком мешает, поскольку речь идет о применении уже отлаженных библиотек, а в распоряжении программиста есть встроенная система помощи. Более того, многие свойства визуальных компонентов даже не фигурируют в том случае, если программа пишется вручную. Значения свойств окон, кнопок и других элементов интерфейса просто задаются в режиме диалога с визуальной системой, и прикладной программист в этой среде по сути имеет дело не со всем языком программирования, а лишь с той его частью, в которую входит возможность использования готовых свойств готовых классов, но не входит их определение.

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

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

_____

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

Весьма неоднозначный смысл имеет конструкция Си#, называемая индексатором. Индексаторы - это элементы классов (а также интерфейсов и структур), позволяющие обращаться к объектам как к массивам (указанием индекса в квадратных скобках). Реализуется такой доступ неявно вызываемыми подпрограммами 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]. В языке Java, где строки - тоже объекты, для обращения к отдельному символу приходится вызывать специальный метод: s.charAt(i).

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

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

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

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

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

_____

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

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

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

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. Но ничего подобного! Напечатанное этой программой значение может оказаться каким угодно. Например, равным 338 350, если “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)) *1, что приведет к неверному результату.

_____

*1 Турбо-Паскаль именно так и поступает.

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

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

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

Обратимся к примеру. Приведенный ниже цикл достаточно типичен для программ на Си: while( b=a[n++] ) { ... };

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

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

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

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

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

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

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

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

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

_____

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

Я пишу эти строки, а текстовый процессор иногда подсказывает мне, что получилось предложение, перегруженное существительными. Он, видимо, считает, что такое предложение вам будет трудно читать и понимать. Но взгляните на программу, написанную на Си#: 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 в паскалеподобных языках), заставляет отличать их описания друг от друга и от описания полей и индексаторов по косвенным признакам. В описании метода после его имени есть круглые скобки; в описании свойства - фигурные; у индексатора - квадратные; в описании поля нет скобок, но может присутствовать знак равенства... Просто тест на внимательность получается.

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

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

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

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

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

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

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

Версия для печати