Common Intermediate Language и системное программирование в Microsoft .NET

Введение в архитектуру Microsoft .NET Framework

Разбить на страницы
Показывать лекцию целиком

Знакомство с .NET

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

Разработка платформы .NET началась в 1998 году. Изначально ей дали рабочее название Project 42, которое затем было изменено на COM Object Runtime (сокращенно, COR). Видимо, аббревиатура COR использовалась достаточно длительное время, так как ее до сих пор можно найти в названиях dll-файлов и именах библиотечных функций. Потом платформа сменила еще несколько названий: Lightning, COM+ 2.0, Next Generation Web Services и, в конце концов, стала называться .NET Framework.

Спецификация основной части платформы .NET стандартизована ассоциацией ECMA (European Computer Manufacturers Association). Это означает, что корпорация Microsoft приветствует независимые реализации платформы.

Главные темы

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

Разработка метаинструментов

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

Мы будем называть метаинструментами программы, для которых другие программы выступают в роли данных. Метаинструменты используются для разработки, тестирования, анализа и преобразования программ. Это могут быть компиляторы, средства быстрой разработки приложений (RAD), оптимизаторы, отладчики, верификаторы, профайлеры и т.п. Знания, полученные из этого учебника, вы сможете применять для создания метаинструментов, которые работают на платформе .NET.

Конструирование компиляторов

В структуре практически любого современного компилятора можно выделить, по крайней мере, две части: "front-end" и "back-end". "Front-end" осуществляет лексический и синтаксический анализ программы и переводит программу в некоторое промежуточное представление. А "back-end" на основе этого промежуточного представления генерирует код для целевой аппаратной платформы. Между этими двумя частями может находиться оптимизатор, анализирующий и преобразующий промежуточное представление программы (см. рис. 1.1).

(рис 1.1) Структура современного компилятора

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

Резюмируя вышесказанное, можно сказать, что в большинстве учебников и учебных курсов, посвященных разработке компиляторов, основное внимание уделяется алгоритмам лексического и синтаксического анализа, то есть они учат в основном программированию "front-end'ов". В нашем учебнике мы концентрируем внимание на архитектуре и языке целевой платформы (.NET), а также изучаем работу с графами потоков управления. Это означает, что мы ориентируемся на программирование "back-end'ов".

Предшественники платформы .NET

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

UCSD p-System

Операционная система UCSD p-System была разработана в 1978 году в Калифорнийском университете для учебных целей. Главное ее достоинство заключалось в том, что она могла работать как на компьютерах PDP-11, стоявших в вычислительном центре университета, так и на домашних микрокомпьютерах студентов.

Независимость операционной системы от аппаратной платформы достигалась путем введения понятия виртуальной p-машины (p-Machine), обладавшей собственным набором инструкций, который назывался p-кодом (p-code). Сама операционная система и все работавшие в ней программы были закодированы на p-коде, поэтому для того чтобы запустить их на новой аппаратной платформе, требовалось всего лишь реализовать для этой платформы интерпретатор p-кода.

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

Платформа .NET использует похожую схему обеспечения независимости программ от аппаратной платформы. Все программы, работающие на платформе .NET, закодированы на языке CIL (Common Intermediate Language), который представляет собой набор инструкций некой абстрактной стековой машины. Основное отличие UCSD p-System от .NET заключается в принципах выполнения программ. Программы, закодированные в p-коде, непосредственно выполнялись интерпретатором, тогда как программы на CIL перед выполнением транслируются в код для конкретного процессора специальным компилятором.

Технология ANDF

Технология ANDF (Architectural Neutral Distribution Format) была разработана в первой половине 1990-х годов в OSF (Open Software Foundation) для увеличения переносимости программного обеспечения. Смысл технологии заключается в разделении процесса компиляции программ на две разнесенные во времени и пространстве фазы:

  • перевод программы в формат ANDF;
  • трансляция программы, представленной в формате ANDF, в исполняемый файл при установке программы на компьютер пользователя.
  • Формат ANDF не зависит ни от языков программирования, ни от особенностей аппаратных платформ и операционных систем. Программы, распространяемые в формате ANDF, могут быть установлены на любой платформе, для которой имеется транслятор из ANDF в исполняемый код.

    Схема использования технологии ANDF показана на рис. 1.2. Для каждого языка программирования реализован компилятор, который генерирует файл в формате ANDF. Такой компилятор называется генератором ANDF (ANDF producer). Для каждой аппаратной платформы реализован инсталлятор ANDF (ANDF installer), который переводит программу из формата ANDF в формат исполняемых файлов.

    (рис 1.2) Схема использования технологии ANDF

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

    Платформа Java

    Платформа Java по архитектуре и своим возможностям наиболее близка к платформе .NET. Она была разработана в середине 1990-х годов в Sun Microsystems для бытовых приборов, подключаемых к компьютерным сетям. Затем произошло стремительное развитие Internet-технологий, которое способствовало широкому распространению Java. В настоящее время Java является основным конкурентом платформы .NET.

    Краеугольным камнем платформы Java является виртуальная машина, которая отвечает за независимость Java-программ от операционных систем и аппаратных платформ. Набор инструкций этой виртуальной машины (так называемый Java byte-code) может выполняться как на специализированных Java-процессорах, так и путем компиляции в исполняемый код конкретной аппаратной платформы.

    Обзор архитектуры .NET

    Платформа .NET состоит из двух основных компонентов. Это Common Language Runtime и .NET Framework Class Library.

    Common Language Runtime (сокращенно CLR) можно назвать "двигателем" платформы .NET. Его задача - обеспечить выполнение приложений .NET, которые, как правило, закодированы на языке CIL, рассчитаны на автоматическое управление памятью и вообще требуют гораздо больше заботы, чем обычные приложения Windows. Поэтому CLR занимается управлением памятью, компиляцией и выполнением кода, работой с потоками управления, обеспечением безопасности и т.п.

    .NET Framework Class Library - это набор классов на все случаи жизни. Далее мы рассмотрим эту библиотеку подробнее, а сейчас остановимся на двух ключевых моментах, которые с ней связаны. Во-первых, на платформе .NET реализованы компиляторы для различных языков программирования, и большинство этих языков позволяют легко использовать одну и ту же библиотеку классов. То есть .NET Framework Class Library - это единая библиотека для всех языков платформы .NET. Во-вторых, использование этой библиотеки позволяет существенно сократить размер приложений, что способствует их распространению через Internet.

    Спецификация CLI

    Разработчику системного программного обеспечения важно понимать, что .NET - всего лишь одна из возможных реализаций так называемой общей инфраструктуры языков (Common Language Infrastructure, сокращенно CLI), спецификация которой разработана корпорацией Microsoft.

    Можно, руководствуясь этой спецификацией, разработать собственную реализацию CLI (рис. 1.3). В настоящее время ведутся по крайней мере два посвященных этому проекта. Это платформа Mono, создаваемая компанией Xamarin, и разрабатываемый в рамках GNU проект Portable .NET. Кроме того, Microsoft распространяет в исходных текстах еще одну свою реализацию CLI, работающую как в Windows, так и под управлением FreeBSD. Эта реализация называется Shared Source CLI (иногда можно услышать другое название - Rotor).

    (рис 1.3) Существующие реализации CLI и поддерживаемые ими операционные системы

    Итак, чтобы понять, как работает .NET, необходимо изучить спецификацию CLI. Этим мы и займемся в ближайшее время, а пока перечислим ее составные части:

  • Общая система типов (Common Type System, сокращенно CTS) - охватывает большую часть типов, встречающихся в распространенных языках программирования.
  • Виртуальная система исполнения (Virtual Execution System, сокращенно VES) - отвечает за загрузку и выполнение программ, написанных для CLI.
  • Система метаданных (Metadata System) - предназначена для описания типов, хранится в независимом от конкретного языка программирования виде, используется для передачи типовой информации между различными метаинструментами, а также между этими инструментами и VES.
  • Общий промежуточный язык (Common Intermediate Language, сокращенно CIL) - независимый от платформы объектно-ориентированный байт-код, выступающий в роли целевого языка для любого поддерживающего CLI компилятора.
  • Общая спецификация языков (Common Language Specification, сокращенно CLS) - соглашение между разработчиками языков программирования и разработчиками библиотек классов, в котором определено подмножество CTS и набор правил. Если разработчики языка реализуют хотя бы определенное в этом соглашении подмножество CTS и при этом действуют в соответствии с указанными правилами, то пользователь языка получает возможность использовать любую соответствующую спецификации CLS библиотеку. То же самое верно и для разработчиков библиотек: если их библиотеки используют только определяемое в соглашении подмножество CTS и при этом написаны в соответствии с указанными правилами, то эти библиотеки можно использовать из любого соответствующего спецификации CLS языка.
  • JIT-компиляция

    Программы для платформы .NET распространяются в виде так называемых сборок (assemblies). Каждая сборка представляет собой совокупность метаданных, описывающих типы, и CIL-кода.

    Ключевой особенностью выполнения программ в среде .NET является JIT-компиляция. Аббревиатура JIT расшифровывается как Just-In-Time, и термин JIT-компиляция можно перевести как компиляция программ "на лету". JIT-компиляция заключается в том, что CIL-код, находящийся в запускаемой сборке, тут же компилируется в машинный код, на который затем передается управление.

    Такая схема выполнения программ в среднем является более эффективной, чем интерпретация инструкций CIL, так как потеря времени на предварительную компиляцию CIL-кода с лихвой компенсируется высокой скоростью работы откомпилированного кода.

    В .NET реализованы два JIT-компилятора: один компилирует сборку непосредственно перед ее выполнением, а другой позволяет откомпилировать ее заранее и поместить в так называемый кэш откомпилированных сборок. JIT-компилятор первого типа вызывается автоматически при запуске программы, а JIT-компилятор второго типа реализован в виде служебной программы ngen, которая входит в состав .NET Framework SDK.

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

    Сборка мусора

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

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

  • Преждевременное освобождение памяти (premature free).

    Эта беда случается, если мы пытаемся использовать объект, память для которого была уже освобождена. Указатели на такие объекты называются висящими (dangling pointers), а обращение по этим указателям дает непредсказуемый результат.

  • Двойное освобождение (double free).

    Иногда бывает важно не перестараться и не освободить ненужный объект дважды.

  • Утечки памяти (memory leaks).

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

  • Фрагментация адресного пространства (external fragmentation).

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

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

    Все эти чудеса, которые творит сборщик мусора, возможны исключительно благодаря тому, что во время выполнения программы известны типы всех используемых в ней объектов. Другими словами, данные, с которыми работает программа, находятся под полным контролем среды выполнения и называются, соответственно, управляемыми данными (managed data).

    Верификация кода

    При разработке платформы .NET было уделено много внимания обеспечению безопасности выполняемого программного кода. С точки зрения обеспечения безопасности можно привести следующую классификацию CIL-кода:

  • Недопустимый код (illegal code).

    Это код, который не может быть обработан JIT-компилятором, то есть не может быть транслирован в машинный код.

  • Допустимый код (legal code).

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

  • Безопасный код (safe code).

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

  • Верифицируемый код (verifiable code).

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

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

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

    Общая система типов

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

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

    Все ошибки можно разделить на две категории: перехватываемые и неперехватываемые (рис. 1.4). При возникновении перехватываемой ошибки (trapped error) выполнение программы немедленно прекращается, а неперехватываемая ошибка (untrapped error) остается незамеченной и может проявиться через некоторое время в абсолютно неожиданном месте.

    (рис 1.4) Классификация ошибок в программах

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

    Фрагмент программы, в котором не могут возникнуть неперехватываемые ошибки, называется безопасным (safe). Языки программирования, которые обеспечивают безопасность написанных на них программ, также называются безопасными (safe languages). Безопасность - одно из важнейших свойств языка. Она уменьшает время отладки благодаря отсутствию в программах неперехватываемых ошибок. Кроме того, она гарантирует целостность данных, что позволяет использовать автоматическое управление памятью (в частности, сборку мусора).

    Для любого языка программирования можно определить класс ошибок, называемых запрещенными (forbidden errors). В этот класс следует включить все неперехватываемые ошибки, а также некоторое подмножество перехватываемых ошибок. Говорят, что фрагмент программы имеет хорошее поведение (well behaved), если в нем не могут возникать запрещенные ошибки. Языки программирования, которые гарантируют хорошее поведение всех написанных на них программ, называются языками со строгой проверкой (strongly checked).

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

  • неперехватываемые ошибки не могут возникнуть;
  • запрещенные перехватываемые ошибки также невозможны;
  • другие перехватываемые ошибки могут возникать, и борьба с ними остается в компетенции программиста.
  • Существуют два пути для диагностики запрещенных ошибок: статическая проверка программы до ее выполнения (static checking) и динамическая проверка во время выполнения (dynamic checking). Статическая проверка характерна для языков, имеющих систему типов, а динамическая проверка - для так называемых бестиповых (typeless) языков, в которых либо вообще нет системы типов, либо существует только один универсальный тип данных. Динамическая проверка требует дополнительных ресурсов, поэтому статическая проверка является предпочтительной, так как чем больше ошибок диагностируется статически, тем выше эффективность программы.

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

    Так как платформа .NET спроектирована с учетом поддержки разных языков программирования, то ее общая система типов (Common Type System - CTS) является объединением систем типов основных распространенных в настоящее время языков. Из этого следует, что все языки платформы .NET (объектно-ориентированные, процедурные, функциональные) совместно используют единую систему типов, и это обеспечивает взаимодействие программных компонентов, написанных на разных языках.

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

    Ядро системы типов .NET

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

    Схема ядра общей системы типов .NET приведена на рис. 1.5. На схеме все типы делятся на две категории: это типы-значения (value types) и ссылочные типы (reference types). Для того чтобы понять причину такого разделения, приведем следующую аналогию. В некоторых языках программирования используются два способа передачи параметров при вызове функции: передача параметра по значению и передача параметров по ссылке. Передача параметра по значению (by value) подразумевает копирование значения параметра, а при передаче параметра по ссылке (by reference) копирования не происходит (вместо этого вызываемая функция получает адрес параметра). Обобщив эту аналогию, мы получим основное отличие типов-значений от ссылочных типов, а именно: использование типов-значений всегда связано с копированием их значений, а работа со ссылочными типами всегда осуществляется через адреса их значений.

    (рис 1.5) Ядро общей системы типов

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

    Ссылочные типы описывают так называемые объектные ссылки (object references), которые представляют собой адреса объектов.

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

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

    Встроенные типы-значения

    Встроенные типы-значения делятся на две группы: целые типы и типы с плавающей запятой. Они перечислены в таблицах 1.1 и 1.2, соответственно. В первом столбце каждой из таблиц приведены имена типов, используемые в текстовом представлении CIL (в программах, компилируемых ассемблером ILASM). Во втором столбце перечислены имена, используемые для тех же самых типов в библиотеке классов .NET. В третьем столбце находится краткое описание, в котором указывается знаковость и разрядность типа.

    Целые типы
    Тип Имя в .NET Framework Class Library Описание
    bool System.Boolean булевский (8 бит)
    char System.Char символ Unicode (16 бит)
    int8 System.SByte целое со знаком (8 бит)
    int16 System.Int16 целое со знаком (16 бит)
    int32 System.Int32 целое со знаком (32 бит)
    int64 System.Int64 целое со знаком (64 бит)
    unsigned int8 System.Byte целое без знака (8 бит)
    unsigned int16 System.Uint16 целое без знака (16 бит)
    unsigned int32 System.Uint32 целое без знака (32 бит)
    unsigned int64 System.UInt64 целое без знака (64 бит)
    native int System.IntPtr целое со знаком (разрядность процессора)
    native unsigned int System.UIntPtr целое без знака (разрядность процессора)

    Типы с плавающей запятой
    Тип Имя в .NET Framework Class Library Описание
    float32 System.Single вещественное (32 бит)
    float64 System.Double вещественное (64 бит)

    Для встроенных типов-значений определены правила преобразования значений одного типа в другой тип. Такие преобразования бывают сужающие (narrowing) и расширяющие (widening). При сужающих преобразованиях значение с большей разрядностью переводится в значение с меньшей разрядностью, что может приводить к потере значащих битов. Расширяющие преобразования никогда не приводят к такой потере.

    Самоописывающие ссылочные типы

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

    В среде .NET объекты и объектные ссылки хранятся раздельно, а именно: объекты хранятся в куче, а ссылки - в ячейках. Поэтому общая система типов спроектирована таким образом, что один и тот же ссылочный тип может являться как типом объекта, так и типом объектной ссылки.

    Каждый объект в куче содержит информацию о своем типе. Поэтому ссылочные типы, представляющие объекты, называются самоописывающими (self-describing).

    Два самоописывающих типа являются встроенными - это System.Object (или просто object в текстовом представлении CIL) и System.String (или string ). Тип System.Object является общим базовым классом, от которого непосредственно или транзитивно наследует любой другой класс. Тип System.String используется для представления строковых данных в формате Unicode.

    Основу самоописывающих типов составляют классы. Классы могут агрегировать значения других типов, а также наследоваться друг от друга (в .NET поддерживается только одиночное наследование). Классы могут содержать следующие элементы:

  • Поля (fields).

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

  • Методы (methods).

    Методы представляют собой функции классов. Они бывают статическими (static method) и объектными (instance method). Вызываемый объектный метод всегда получает ссылку на объект, для которого он вызывается. Объектные методы делятся на виртуальные и невиртуальные.

  • Свойства (properties).

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

  • События (events).

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

  • Типы-массивы также относятся к самоописывающим типам, то есть каждый массив представляет собой объект в куче, доступ к которому осуществляется через объектную ссылку. Хотя, строго говоря, типы-массивы не являются классами, считается, что все они наследуют от библиотечного класса System.Array. Типы-массивы интересны тем, что, в отличие от классов, определяемых программистом самостоятельно, они формируются системой автоматически. То есть если мы имеем некоторый тип X, то тип массива, состоящего из элементов типа X, нам уже объявлять не нужно - об этом позаботится система выполнения.

    (рис 1.6) Особенности представления массивов

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

    Особого внимания заслуживают особенности представления массивов объектов и массивов типов-значений. Дело в том, что так как объекты не могут храниться в ячейках, мы вынуждены вместо массивов объектов использовать массивы объектных ссылок (см. рис. 1.6), в то время как значения типов-значений хранятся прямо в элементах массива.

    Типы-интерфейсы

    Интерфейсы служат для компенсации отсутствия в .NET множественного наследования. Они могут рассматриваться как чисто абстрактные классы, содержащие только перечисленные ниже элементы:

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

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

    Совместимость ячеек по присваиванию

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

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

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

    Идентичность и равенство значений

    Для объектных ссылок и значений типов-значений вводятся отношения идентичности (identity) и равенства (equality). Эти отношения являются отношениями эквивалентности, то есть они рефлексивны, симметричны и транзитивны.

    Отношение идентичности для объектных ссылок вводится следующим образом: две объектных ссылки идентичны тогда и только тогда, когда они содержат адреса одного и того же объекта. На рис. 1.7 изображены три объектных ссылки A, B и C, а также два равных объекта-строки. Так как ссылки A и B содержат адрес одного и того же объекта, то они идентичны между собой, но при этом они не идентичны ссылке C, содержащей адрес другого объекта.

    (рис 1.7) Пример, объясняющий отношения идентичности и равенства объектных ссылок

    Отношение равенства для объектных ссылок формулируется так: две объектных ссылки равны тогда и только тогда, когда они содержат адреса равных объектов. Все три объектные ссылки, изображенные на рис. 1.7, равны между собой.

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

    Отношение равенства для примитивных типов-значений совпадает с отношением идентичности.

    Отношения идентичности и равенства играют большую роль при программировании в среде .NET. Любой объект имеет виртуальный метод Equals, унаследованный от System.Object и выполняющий сравнение объектов на равенство. В зависимости от реализации этого метода, отношение равенства может существенно меняться. При переопределении метода Equals нужно иметь в виду, что два идентичных объекта обязательно должны быть равны.

    Дополнительные элементы системы типов .NET

    Из соображений эффективности выполнения программ разработчики платформы .NET добавили в общую систему типов дополнительные элементы, а именно: пользовательские типы-значения (структуры и перечисления) и указатели. Схема общей системы типов, на которой отражены эти дополнительные элементы, приведена на рис. 1.8.

    (рис 1.8) Общая система типов

    Структуры и перечисления

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

  • Интенсивное создание временных объектов с очень малым временем жизни. Зачастую такие объекты создаются и используются в теле одного метода.
  • Использование гигантских массивов объектов, при котором возникает ситуация, изображенная на рис. 1.6, а именно: в массиве хранятся ссылки на огромное количество небольших объектов.
  • Разработчиками .NET был подмечен тот факт, что использование типов-значений вместо объектов позволяет избежать описанных выше проблем, потому что:

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

    Пользовательские типы-значения делятся на структуры и перечисления.

    Структуры являются аналогом классов. Они, как и классы, могут содержать поля, методы, свойства и события. Все структуры неявно наследуют от библиотечного класса System.ValueType, и, более того, встроенные типы-значения также наследуют от этого класса. Тут сразу следует заметить, что система типов не предусматривает никакого наследования структур, кроме данного неявного. Другими словами, структуры не могут наследоваться друг от друга и, тем более, не могут наследоваться от классов (кроме System.ValueType ).

    Перечисления представляют собой структуры с одним целочисленным полем Value. Кроме того, перечисления содержат набор констант, определяющих возможные значения поля Value. При этом для каждой константы в перечислении хранится ее имя. Перечисления неявно наследуют от библиотечного класса System.Enum, который, в свою очередь, является наследником все того же класса System.ValueType.

    Указатели

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

    Тем не менее, разработчикам .NET удалось добавить указатели в общую систему типов. При этом появилось две категории указателей: управляемые указатели (managed pointers) и неуправляемые указатели (unmanaged pointers).

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

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

    Упакованные типы-значения

    Наличие в общей системе типов структур, которые во многом напоминают классы, но в действительности классами не являются, в некоторых случаях вызывает некоторые неудобства. Например, в библиотеке классов .NET существуют достаточно удобные контейнерные классы (наиболее часто используется класс ArrayList, представляющий массив с динамически меняющимся размером). Эти классы могут хранить ссылки на любые объекты, но не могут работать с типами-значениями.

    Для решения этой проблемы в общей системе типов предусмотрены так называемые упакованные типы-значения. Эти типы являются ссылочными и самоописывающими. Объекты этих типов предназначены для хранения значений типов-значений.

    Упакованные типы-значения не могут быть объявлены программистом. Система автоматически определяет такой тип для любого типа-значения.

    Получение объекта упакованного типа-значения осуществляется путем упаковки (boxing). Упаковка заключается в том, что в куче создается пустой объект нужного размера, а затем значение копируется внутрь этого объекта.

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

    Если же нам требуется произвести обратное действие, мы можем осуществить распаковку (unboxing). Распаковка заключается в том, что мы получаем управляемый указатель на содержимое объекта упакованного типа-значения.

    Страницы:

    Знакомство с .NET

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

    Разработка платформы .NET началась в 1998 году. Изначально ей дали рабочее название Project 42, которое затем было изменено на COM Object Runtime (сокращенно, COR). Видимо, аббревиатура COR использовалась достаточно длительное время, так как ее до сих пор можно найти в названиях dll-файлов и именах библиотечных функций. Потом платформа сменила еще несколько названий: Lightning, COM+ 2.0, Next Generation Web Services и, в конце концов, стала называться .NET Framework.

    Спецификация основной части платформы .NET стандартизована ассоциацией ECMA (European Computer Manufacturers Association). Это означает, что корпорация Microsoft приветствует независимые реализации платформы.

    Главные темы

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

    Разработка метаинструментов

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

    Мы будем называть метаинструментами программы, для которых другие программы выступают в роли данных. Метаинструменты используются для разработки, тестирования, анализа и преобразования программ. Это могут быть компиляторы, средства быстрой разработки приложений (RAD), оптимизаторы, отладчики, верификаторы, профайлеры и т.п. Знания, полученные из этого учебника, вы сможете применять для создания метаинструментов, которые работают на платформе .NET.

    Конструирование компиляторов

    В структуре практически любого современного компилятора можно выделить, по крайней мере, две части: "front-end" и "back-end". "Front-end" осуществляет лексический и синтаксический анализ программы и переводит программу в некоторое промежуточное представление. А "back-end" на основе этого промежуточного представления генерирует код для целевой аппаратной платформы. Между этими двумя частями может находиться оптимизатор, анализирующий и преобразующий промежуточное представление программы (см. рис. 1.1).

    (рис 1.1) Структура современного компилятора

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

    Резюмируя вышесказанное, можно сказать, что в большинстве учебников и учебных курсов, посвященных разработке компиляторов, основное внимание уделяется алгоритмам лексического и синтаксического анализа, то есть они учат в основном программированию "front-end'ов". В нашем учебнике мы концентрируем внимание на архитектуре и языке целевой платформы (.NET), а также изучаем работу с графами потоков управления. Это означает, что мы ориентируемся на программирование "back-end'ов".

    Предшественники платформы .NET

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

    UCSD p-System

    Операционная система UCSD p-System была разработана в 1978 году в Калифорнийском университете для учебных целей. Главное ее достоинство заключалось в том, что она могла работать как на компьютерах PDP-11, стоявших в вычислительном центре университета, так и на домашних микрокомпьютерах студентов.

    Независимость операционной системы от аппаратной платформы достигалась путем введения понятия виртуальной p-машины (p-Machine), обладавшей собственным набором инструкций, который назывался p-кодом (p-code). Сама операционная система и все работавшие в ней программы были закодированы на p-коде, поэтому для того чтобы запустить их на новой аппаратной платформе, требовалось всего лишь реализовать для этой платформы интерпретатор p-кода.

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

    Платформа .NET использует похожую схему обеспечения независимости программ от аппаратной платформы. Все программы, работающие на платформе .NET, закодированы на языке CIL (Common Intermediate Language), который представляет собой набор инструкций некой абстрактной стековой машины. Основное отличие UCSD p-System от .NET заключается в принципах выполнения программ. Программы, закодированные в p-коде, непосредственно выполнялись интерпретатором, тогда как программы на CIL перед выполнением транслируются в код для конкретного процессора специальным компилятором.

    Технология ANDF

    Технология ANDF (Architectural Neutral Distribution Format) была разработана в первой половине 1990-х годов в OSF (Open Software Foundation) для увеличения переносимости программного обеспечения. Смысл технологии заключается в разделении процесса компиляции программ на две разнесенные во времени и пространстве фазы:

  • перевод программы в формат ANDF;
  • трансляция программы, представленной в формате ANDF, в исполняемый файл при установке программы на компьютер пользователя.
  • Формат ANDF не зависит ни от языков программирования, ни от особенностей аппаратных платформ и операционных систем. Программы, распространяемые в формате ANDF, могут быть установлены на любой платформе, для которой имеется транслятор из ANDF в исполняемый код.

    Схема использования технологии ANDF показана на рис. 1.2. Для каждого языка программирования реализован компилятор, который генерирует файл в формате ANDF. Такой компилятор называется генератором ANDF (ANDF producer). Для каждой аппаратной платформы реализован инсталлятор ANDF (ANDF installer), который переводит программу из формата ANDF в формат исполняемых файлов.

    (рис 1.2) Схема использования технологии ANDF

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

    Платформа Java

    Платформа Java по архитектуре и своим возможностям наиболее близка к платформе .NET. Она была разработана в середине 1990-х годов в Sun Microsystems для бытовых приборов, подключаемых к компьютерным сетям. Затем произошло стремительное развитие Internet-технологий, которое способствовало широкому распространению Java. В настоящее время Java является основным конкурентом платформы .NET.

    Краеугольным камнем платформы Java является виртуальная машина, которая отвечает за независимость Java-программ от операционных систем и аппаратных платформ. Набор инструкций этой виртуальной машины (так называемый Java byte-code) может выполняться как на специализированных Java-процессорах, так и путем компиляции в исполняемый код конкретной аппаратной платформы.

    Обзор архитектуры .NET

    Платформа .NET состоит из двух основных компонентов. Это Common Language Runtime и .NET Framework Class Library.

    Common Language Runtime (сокращенно CLR) можно назвать "двигателем" платформы .NET. Его задача - обеспечить выполнение приложений .NET, которые, как правило, закодированы на языке CIL, рассчитаны на автоматическое управление памятью и вообще требуют гораздо больше заботы, чем обычные приложения Windows. Поэтому CLR занимается управлением памятью, компиляцией и выполнением кода, работой с потоками управления, обеспечением безопасности и т.п.

    .NET Framework Class Library - это набор классов на все случаи жизни. Далее мы рассмотрим эту библиотеку подробнее, а сейчас остановимся на двух ключевых моментах, которые с ней связаны. Во-первых, на платформе .NET реализованы компиляторы для различных языков программирования, и большинство этих языков позволяют легко использовать одну и ту же библиотеку классов. То есть .NET Framework Class Library - это единая библиотека для всех языков платформы .NET. Во-вторых, использование этой библиотеки позволяет существенно сократить размер приложений, что способствует их распространению через Internet.

    Спецификация CLI

    Разработчику системного программного обеспечения важно понимать, что .NET - всего лишь одна из возможных реализаций так называемой общей инфраструктуры языков (Common Language Infrastructure, сокращенно CLI), спецификация которой разработана корпорацией Microsoft.

    Можно, руководствуясь этой спецификацией, разработать собственную реализацию CLI (рис. 1.3). В настоящее время ведутся по крайней мере два посвященных этому проекта. Это платформа Mono, создаваемая компанией Xamarin, и разрабатываемый в рамках GNU проект Portable .NET. Кроме того, Microsoft распространяет в исходных текстах еще одну свою реализацию CLI, работающую как в Windows, так и под управлением FreeBSD. Эта реализация называется Shared Source CLI (иногда можно услышать другое название - Rotor).

    (рис 1.3) Существующие реализации CLI и поддерживаемые ими операционные системы

    Итак, чтобы понять, как работает .NET, необходимо изучить спецификацию CLI. Этим мы и займемся в ближайшее время, а пока перечислим ее составные части:

  • Общая система типов (Common Type System, сокращенно CTS) - охватывает большую часть типов, встречающихся в распространенных языках программирования.
  • Виртуальная система исполнения (Virtual Execution System, сокращенно VES) - отвечает за загрузку и выполнение программ, написанных для CLI.
  • Система метаданных (Metadata System) - предназначена для описания типов, хранится в независимом от конкретного языка программирования виде, используется для передачи типовой информации между различными метаинструментами, а также между этими инструментами и VES.
  • Общий промежуточный язык (Common Intermediate Language, сокращенно CIL) - независимый от платформы объектно-ориентированный байт-код, выступающий в роли целевого языка для любого поддерживающего CLI компилятора.
  • Общая спецификация языков (Common Language Specification, сокращенно CLS) - соглашение между разработчиками языков программирования и разработчиками библиотек классов, в котором определено подмножество CTS и набор правил. Если разработчики языка реализуют хотя бы определенное в этом соглашении подмножество CTS и при этом действуют в соответствии с указанными правилами, то пользователь языка получает возможность использовать любую соответствующую спецификации CLS библиотеку. То же самое верно и для разработчиков библиотек: если их библиотеки используют только определяемое в соглашении подмножество CTS и при этом написаны в соответствии с указанными правилами, то эти библиотеки можно использовать из любого соответствующего спецификации CLS языка.
  • JIT-компиляция

    Программы для платформы .NET распространяются в виде так называемых сборок (assemblies). Каждая сборка представляет собой совокупность метаданных, описывающих типы, и CIL-кода.

    Ключевой особенностью выполнения программ в среде .NET является JIT-компиляция. Аббревиатура JIT расшифровывается как Just-In-Time, и термин JIT-компиляция можно перевести как компиляция программ "на лету". JIT-компиляция заключается в том, что CIL-код, находящийся в запускаемой сборке, тут же компилируется в машинный код, на который затем передается управление.

    Такая схема выполнения программ в среднем является более эффективной, чем интерпретация инструкций CIL, так как потеря времени на предварительную компиляцию CIL-кода с лихвой компенсируется высокой скоростью работы откомпилированного кода.

    В .NET реализованы два JIT-компилятора: один компилирует сборку непосредственно перед ее выполнением, а другой позволяет откомпилировать ее заранее и поместить в так называемый кэш откомпилированных сборок. JIT-компилятор первого типа вызывается автоматически при запуске программы, а JIT-компилятор второго типа реализован в виде служебной программы ngen, которая входит в состав .NET Framework SDK.

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

    Сборка мусора

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

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

  • Преждевременное освобождение памяти (premature free).

    Эта беда случается, если мы пытаемся использовать объект, память для которого была уже освобождена. Указатели на такие объекты называются висящими (dangling pointers), а обращение по этим указателям дает непредсказуемый результат.

  • Двойное освобождение (double free).

    Иногда бывает важно не перестараться и не освободить ненужный объект дважды.

  • Утечки памяти (memory leaks).

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

  • Фрагментация адресного пространства (external fragmentation).

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

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

    Все эти чудеса, которые творит сборщик мусора, возможны исключительно благодаря тому, что во время выполнения программы известны типы всех используемых в ней объектов. Другими словами, данные, с которыми работает программа, находятся под полным контролем среды выполнения и называются, соответственно, управляемыми данными (managed data).

    Верификация кода

    При разработке платформы .NET было уделено много внимания обеспечению безопасности выполняемого программного кода. С точки зрения обеспечения безопасности можно привести следующую классификацию CIL-кода:

  • Недопустимый код (illegal code).

    Это код, который не может быть обработан JIT-компилятором, то есть не может быть транслирован в машинный код.

  • Допустимый код (legal code).

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

  • Безопасный код (safe code).

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

  • Верифицируемый код (verifiable code).

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

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

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

    Общая система типов

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

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

    Все ошибки можно разделить на две категории: перехватываемые и неперехватываемые (рис. 1.4). При возникновении перехватываемой ошибки (trapped error) выполнение программы немедленно прекращается, а неперехватываемая ошибка (untrapped error) остается незамеченной и может проявиться через некоторое время в абсолютно неожиданном месте.

    (рис 1.4) Классификация ошибок в программах

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

    Фрагмент программы, в котором не могут возникнуть неперехватываемые ошибки, называется безопасным (safe). Языки программирования, которые обеспечивают безопасность написанных на них программ, также называются безопасными (safe languages). Безопасность - одно из важнейших свойств языка. Она уменьшает время отладки благодаря отсутствию в программах неперехватываемых ошибок. Кроме того, она гарантирует целостность данных, что позволяет использовать автоматическое управление памятью (в частности, сборку мусора).

    Для любого языка программирования можно определить класс ошибок, называемых запрещенными (forbidden errors). В этот класс следует включить все неперехватываемые ошибки, а также некоторое подмножество перехватываемых ошибок. Говорят, что фрагмент программы имеет хорошее поведение (well behaved), если в нем не могут возникать запрещенные ошибки. Языки программирования, которые гарантируют хорошее поведение всех написанных на них программ, называются языками со строгой проверкой (strongly checked).

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

  • неперехватываемые ошибки не могут возникнуть;
  • запрещенные перехватываемые ошибки также невозможны;
  • другие перехватываемые ошибки могут возникать, и борьба с ними остается в компетенции программиста.
  • Существуют два пути для диагностики запрещенных ошибок: статическая проверка программы до ее выполнения (static checking) и динамическая проверка во время выполнения (dynamic checking). Статическая проверка характерна для языков, имеющих систему типов, а динамическая проверка - для так называемых бестиповых (typeless) языков, в которых либо вообще нет системы типов, либо существует только один универсальный тип данных. Динамическая проверка требует дополнительных ресурсов, поэтому статическая проверка является предпочтительной, так как чем больше ошибок диагностируется статически, тем выше эффективность программы.

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

    Так как платформа .NET спроектирована с учетом поддержки разных языков программирования, то ее общая система типов (Common Type System - CTS) является объединением систем типов основных распространенных в настоящее время языков. Из этого следует, что все языки платформы .NET (объектно-ориентированные, процедурные, функциональные) совместно используют единую систему типов, и это обеспечивает взаимодействие программных компонентов, написанных на разных языках.

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

    Ядро системы типов .NET

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

    Схема ядра общей системы типов .NET приведена на рис. 1.5. На схеме все типы делятся на две категории: это типы-значения (value types) и ссылочные типы (reference types). Для того чтобы понять причину такого разделения, приведем следующую аналогию. В некоторых языках программирования используются два способа передачи параметров при вызове функции: передача параметра по значению и передача параметров по ссылке. Передача параметра по значению (by value) подразумевает копирование значения параметра, а при передаче параметра по ссылке (by reference) копирования не происходит (вместо этого вызываемая функция получает адрес параметра). Обобщив эту аналогию, мы получим основное отличие типов-значений от ссылочных типов, а именно: использование типов-значений всегда связано с копированием их значений, а работа со ссылочными типами всегда осуществляется через адреса их значений.

    (рис 1.5) Ядро общей системы типов

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

    Ссылочные типы описывают так называемые объектные ссылки (object references), которые представляют собой адреса объектов.

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

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

    Встроенные типы-значения

    Встроенные типы-значения делятся на две группы: целые типы и типы с плавающей запятой. Они перечислены в таблицах 1.1 и 1.2, соответственно. В первом столбце каждой из таблиц приведены имена типов, используемые в текстовом представлении CIL (в программах, компилируемых ассемблером ILASM). Во втором столбце перечислены имена, используемые для тех же самых типов в библиотеке классов .NET. В третьем столбце находится краткое описание, в котором указывается знаковость и разрядность типа.

    Целые типы
    Тип Имя в .NET Framework Class Library Описание
    bool System.Boolean булевский (8 бит)
    char System.Char символ Unicode (16 бит)
    int8 System.SByte целое со знаком (8 бит)
    int16 System.Int16 целое со знаком (16 бит)
    int32 System.Int32 целое со знаком (32 бит)
    int64 System.Int64 целое со знаком (64 бит)
    unsigned int8 System.Byte целое без знака (8 бит)
    unsigned int16 System.Uint16 целое без знака (16 бит)
    unsigned int32 System.Uint32 целое без знака (32 бит)
    unsigned int64 System.UInt64 целое без знака (64 бит)
    native int System.IntPtr целое со знаком (разрядность процессора)
    native unsigned int System.UIntPtr целое без знака (разрядность процессора)

    Типы с плавающей запятой
    Тип Имя в .NET Framework Class Library Описание
    float32 System.Single вещественное (32 бит)
    float64 System.Double вещественное (64 бит)

    Для встроенных типов-значений определены правила преобразования значений одного типа в другой тип. Такие преобразования бывают сужающие (narrowing) и расширяющие (widening). При сужающих преобразованиях значение с большей разрядностью переводится в значение с меньшей разрядностью, что может приводить к потере значащих битов. Расширяющие преобразования никогда не приводят к такой потере.

    Самоописывающие ссылочные типы

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

    В среде .NET объекты и объектные ссылки хранятся раздельно, а именно: объекты хранятся в куче, а ссылки - в ячейках. Поэтому общая система типов спроектирована таким образом, что один и тот же ссылочный тип может являться как типом объекта, так и типом объектной ссылки.

    Каждый объект в куче содержит информацию о своем типе. Поэтому ссылочные типы, представляющие объекты, называются самоописывающими (self-describing).

    Два самоописывающих типа являются встроенными - это System.Object (или просто object в текстовом представлении CIL) и System.String (или string ). Тип System.Object является общим базовым классом, от которого непосредственно или транзитивно наследует любой другой класс. Тип System.String используется для представления строковых данных в формате Unicode.

    Основу самоописывающих типов составляют классы. Классы могут агрегировать значения других типов, а также наследоваться друг от друга (в .NET поддерживается только одиночное наследование). Классы могут содержать следующие элементы:

  • Поля (fields).

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

  • Методы (methods).

    Методы представляют собой функции классов. Они бывают статическими (static method) и объектными (instance method). Вызываемый объектный метод всегда получает ссылку на объект, для которого он вызывается. Объектные методы делятся на виртуальные и невиртуальные.

  • Свойства (properties).

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

  • События (events).

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

  • Типы-массивы также относятся к самоописывающим типам, то есть каждый массив представляет собой объект в куче, доступ к которому осуществляется через объектную ссылку. Хотя, строго говоря, типы-массивы не являются классами, считается, что все они наследуют от библиотечного класса System.Array. Типы-массивы интересны тем, что, в отличие от классов, определяемых программистом самостоятельно, они формируются системой автоматически. То есть если мы имеем некоторый тип X, то тип массива, состоящего из элементов типа X, нам уже объявлять не нужно - об этом позаботится система выполнения.

    (рис 1.6) Особенности представления массивов

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

    Особого внимания заслуживают особенности представления массивов объектов и массивов типов-значений. Дело в том, что так как объекты не могут храниться в ячейках, мы вынуждены вместо массивов объектов использовать массивы объектных ссылок (см. рис. 1.6), в то время как значения типов-значений хранятся прямо в элементах массива.

    Типы-интерфейсы

    Интерфейсы служат для компенсации отсутствия в .NET множественного наследования. Они могут рассматриваться как чисто абстрактные классы, содержащие только перечисленные ниже элементы:

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

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

    Совместимость ячеек по присваиванию

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

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

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

    Идентичность и равенство значений

    Для объектных ссылок и значений типов-значений вводятся отношения идентичности (identity) и равенства (equality). Эти отношения являются отношениями эквивалентности, то есть они рефлексивны, симметричны и транзитивны.

    Отношение идентичности для объектных ссылок вводится следующим образом: две объектных ссылки идентичны тогда и только тогда, когда они содержат адреса одного и того же объекта. На рис. 1.7 изображены три объектных ссылки A, B и C, а также два равных объекта-строки. Так как ссылки A и B содержат адрес одного и того же объекта, то они идентичны между собой, но при этом они не идентичны ссылке C, содержащей адрес другого объекта.

    (рис 1.7) Пример, объясняющий отношения идентичности и равенства объектных ссылок

    Отношение равенства для объектных ссылок формулируется так: две объектных ссылки равны тогда и только тогда, когда они содержат адреса равных объектов. Все три объектные ссылки, изображенные на рис. 1.7, равны между собой.

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

    Отношение равенства для примитивных типов-значений совпадает с отношением идентичности.

    Отношения идентичности и равенства играют большую роль при программировании в среде .NET. Любой объект имеет виртуальный метод Equals, унаследованный от System.Object и выполняющий сравнение объектов на равенство. В зависимости от реализации этого метода, отношение равенства может существенно меняться. При переопределении метода Equals нужно иметь в виду, что два идентичных объекта обязательно должны быть равны.

    Дополнительные элементы системы типов .NET

    Из соображений эффективности выполнения программ разработчики платформы .NET добавили в общую систему типов дополнительные элементы, а именно: пользовательские типы-значения (структуры и перечисления) и указатели. Схема общей системы типов, на которой отражены эти дополнительные элементы, приведена на рис. 1.8.

    (рис 1.8) Общая система типов

    Структуры и перечисления

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

  • Интенсивное создание временных объектов с очень малым временем жизни. Зачастую такие объекты создаются и используются в теле одного метода.
  • Использование гигантских массивов объектов, при котором возникает ситуация, изображенная на рис. 1.6, а именно: в массиве хранятся ссылки на огромное количество небольших объектов.
  • Разработчиками .NET был подмечен тот факт, что использование типов-значений вместо объектов позволяет избежать описанных выше проблем, потому что:

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

    Пользовательские типы-значения делятся на структуры и перечисления.

    Структуры являются аналогом классов. Они, как и классы, могут содержать поля, методы, свойства и события. Все структуры неявно наследуют от библиотечного класса System.ValueType, и, более того, встроенные типы-значения также наследуют от этого класса. Тут сразу следует заметить, что система типов не предусматривает никакого наследования структур, кроме данного неявного. Другими словами, структуры не могут наследоваться друг от друга и, тем более, не могут наследоваться от классов (кроме System.ValueType ).

    Перечисления представляют собой структуры с одним целочисленным полем Value. Кроме того, перечисления содержат набор констант, определяющих возможные значения поля Value. При этом для каждой константы в перечислении хранится ее имя. Перечисления неявно наследуют от библиотечного класса System.Enum, который, в свою очередь, является наследником все того же класса System.ValueType.

    Указатели

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

    Тем не менее, разработчикам .NET удалось добавить указатели в общую систему типов. При этом появилось две категории указателей: управляемые указатели (managed pointers) и неуправляемые указатели (unmanaged pointers).

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

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

    Упакованные типы-значения

    Наличие в общей системе типов структур, которые во многом напоминают классы, но в действительности классами не являются, в некоторых случаях вызывает некоторые неудобства. Например, в библиотеке классов .NET существуют достаточно удобные контейнерные классы (наиболее часто используется класс ArrayList, представляющий массив с динамически меняющимся размером). Эти классы могут хранить ссылки на любые объекты, но не могут работать с типами-значениями.

    Для решения этой проблемы в общей системе типов предусмотрены так называемые упакованные типы-значения. Эти типы являются ссылочными и самоописывающими. Объекты этих типов предназначены для хранения значений типов-значений.

    Упакованные типы-значения не могут быть объявлены программистом. Система автоматически определяет такой тип для любого типа-значения.

    Получение объекта упакованного типа-значения осуществляется путем упаковки (boxing). Упаковка заключается в том, что в куче создается пустой объект нужного размера, а затем значение копируется внутрь этого объекта.

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

    Если же нам требуется произвести обратное действие, мы можем осуществить распаковку (unboxing). Распаковка заключается в том, что мы получаем управляемый указатель на содержимое объекта упакованного типа-значения.

    Вернуться к учебному плану