A Closer Look at Intermediate Language
From what you learned in the previous
section, Microsoft Intermediate Language obviously plays a
fundamental role in the .NET Framework. As C# developers, we now
understand that our C# code will be compiled into IL before it is
executed (indeed, the C# compiler only
compiles to managed code). It makes sense, then, to now take a
closer look at the main characteristics of IL, because any language
that targets .NET will logically need to support the main
characteristics of IL, too.
Here are the important features of IL:
-
Object orientation and use of interfaces
-
Strong distinction between value and
reference types
-
Strong data typing
-
Error handling through the use of
exceptions
-
Use of attributes
The following sections take a closer look at each
of these characteristics.
Support for Object Orientation and
Interfaces
The language independence of .NET does have
some practical limitations. IL is inevitably going to implement
some particular programming methodology, which means that languages
targeting it are going to have to be compatible with that
methodology. The particular route that Microsoft has chosen to
follow for IL is that of classic object-oriented programming, with
single implementation inheritance of classes.
|
|
Tip |
If you are unfamiliar with the concepts of
object orientation, refer to Appendix A for more information.
Appendix A is posted at www.wrox.com.
|
In addition to classic object-oriented programming,
IL also brings in the idea of interfaces, which saw their first
implementation under Windows with COM. .NET interfaces are not the
same as COM interfaces; they do not need to support any of the COM
infrastructure (for example, they are not derived from IUnknown, and they do not have associated globally
unique identifiers, more commonly know as GUIDs). However, they do
share with COM interfaces the idea that they provide a contract,
and classes that implement a given interface must provide
implementations of the methods and properties specified by that
interface.
You have now seen that working with .NET means
compiling to IL, and that in turn means that you will need to use
traditional object-oriented methodologies. However, that alone is
not sufficient to give you language interoperability. After all,
C++ and Java both use the same object-oriented paradigms, but they
are still not regarded as interoperable. We need to look a little
more closely at the concept of language interoperability.
To start with, we need to consider exactly what we
mean by language interoperability. After all, COM allowed
components written in different languages to work together in the
sense of calling each other’s methods. What was inadequate about
that? COM, by virtue of being a binary standard, did allow
components to instantiate other components and call methods or
properties against them, without worrying about the language the
respective components were written in. In order to achieve this,
however, each object had to be instantiated through the COM
runtime, and accessed through an interface. Depending on the
threading models of the relative components, there may have been
large performance losses associated with marshaling data between
apartments or running components or both on different threads. In
the extreme case of components hosted as an executable rather than
DLL files, separate processes would need to be created in order to
run them. The emphasis was very much that components could talk to
each other but only via the COM runtime. In no way with COM did
components written in different languages directly communicate with
each other, or instantiate instances of each other - it was always
done with COM as an intermediary. Not only that, but the COM
architecture did not permit
implementation inheritance, which meant that it lost many of the
advantages of object-oriented programming.
An associated problem was that, when debugging, you
would still have to debug components written in different languages
independently. It was not possible to step between languages in the
debugger. So what we really mean by
language interoperability is that classes written in one language
should be able to talk directly to classes written in another
language. In particular:
-
A class written in one language can inherit
from a class written in another language.
-
The class can contain an instance of another
class, no matter what the languages of the two classes are.
-
An object can directly call methods against
another object written in another language.
-
Objects (or references to objects) can be
passed around between methods.
-
When calling methods between languages you
can step between the method calls in the debugger, even when this
means stepping between source code written in different
languages.
This is all quite an ambitious aim, but
amazingly, .NET and IL have achieved it. In the case of stepping
between methods in the debugger, this facility is really offered by
the Visual Studio .NET integrated development environment (IDE)
rather than by the CLR itself.
Distinct Value and Reference Types
As with any programming language, IL provides
a number of predefined primitive data types. One characteristic of
IL, however, is that it makes a strong distinction between value
and reference types. Value types are those for which a variable directly
stores its data, whereas reference types are those for which a variable
simply stores the address at which the corresponding data can be
found.
In C++ terms, using reference types can be
considered to be similar to accessing a variable through a pointer,
whereas for Visual Basic, the best analogy for reference types are
objects, which in Visual Basic 6 are always accessed through
references. IL also lays down specifications about data storage:
instances of reference types are always stored in an area of memory
known as the managed heap, whereas value types are normally
stored on the stack (although if value types are declared as
fields within reference types, they will be stored inline on the
heap). Chapter 2, “C# Basics,” discusses the
stack and the heap and how they work.
Strong Data Typing
One very important aspect of IL is that it is
based on exceptionally strong data typing.
That means that all variables are clearly marked as being of a
particular, specific data type (there is no room in IL, for
example, for the Variant data type
recognized by Visual Basic and scripting languages). In particular,
IL does not normally permit any operations that result in ambiguous
data types.
For instance, Visual Basic 6 developers are used to
being able to pass variables around without worrying too much about
their types, because Visual Basic 6 automatically performs type
conversion. C++ developers are used to routinely casting pointers
between different types. Being able to perform this kind of
operation can be great for performance, but it breaks type safety.
Hence, it is permitted only under certain circumstances in some of the
languages that compile to managed code. Indeed, pointers (as
opposed to references) are permitted only in marked blocks of code
in C#, and not at all in Visual Basic (although they are allowed in
managed C++). Using pointers in your code causes it to fail the
memory type safety checks performed by the CLR.
You should note that some languages compatible with
.NET, such as Visual Basic 2009, still allow some laxity in typing,
but that is only possible because the compilers behind the scenes
ensure that the type safety is enforced in the emitted IL.
Although enforcing type safety might initially
appear to hurt performance, in many cases the benefits gained from
the services provided by .NET that rely on type safety far outweigh
this performance loss. Such services include:
The following sections take a closer look at why
strong data typing is particularly important for these features of
.NET.
The
Importance of Strong Data Typing for Language Interoperability
If a class is to derive from or contains
instances of other classes, it needs to know about all the data
types used by the other classes. This is why strong data typing is
so important. Indeed, it is the absence of any agreed-on system for
specifying this information in the past that has always been the
real barrier to inheritance and interoperability across languages.
This kind of information is simply not present in a standard
executable file or DLL.
Suppose that one of the methods of a Visual Basic
2009 class is defined to return an Integer - one of the standard data types available
in Visual Basic 2009. C# simply does not have any data type of that
name. Clearly, you will only be able to derive from the class, use
this method, and use the return type from C# code, if the compiler
knows how to map Visual Basic 2009’s Integer type to some known type that is defined in
C#. So, how is this problem circumvented in .NET?
Common Type System
This data type problem is solved in .NET
through the use of the Common Type System
(CTS). The CTS defines the predefined data types that are available
in IL, so that all languages that target the .NET Framework will
produce compiled code that is ultimately based on these types.
For the previous example, Visual Basic 2009’s
Integer is actually a 32-bit signed
integer, which maps exactly to the IL type known as Int32. This will therefore be the data type
specified in the IL code. Because the C# compiler is aware of this
type, there is no problem. At source code level, C# refers to
Int32 with the keyword int, so the compiler will simply treat the Visual
Basic 2009 method as if it returned an int.
The CTS doesn’t specify merely primitive data types
but a rich hierarchy of types, which includes well-defined points
in the hierarchy at which code is permitted to define its own
types. The hierarchical structure of the CTS reflects the
single-inheritance object-oriented methodology of IL, and resembles
Figure 1-1.
The following table explains the types shown in
Figure 1-1.
We won’t list all of the built-in value types
here, because they are covered in detail in Chapter 3, “Objects and
Types.” In C#, each predefined type recognized by the compiler
maps onto one of the IL built-in types. The same is true in Visual
Basic 2009.
Common Language Specification
The Common Language Specification (CLS) works
with the CTS to ensure language interoperability. The CLS is a set
of minimum standards that all compilers targeting .NET must
support. Because IL is a very rich language, writers of most
compilers will prefer to restrict the capabilities of a given
compiler to only support a subset of the facilities offered by IL
and the CTS. That is fine, as long as the compiler supports
everything that is defined in the CLS.
|
|
Important |
It is perfectly acceptable to write
non-CLS-compliant code. However, if you do, the compiled IL code
isn’t guaranteed to be fully language interoperable.
|
For example, take case sensitivity. IL is case
sensitive. Developers who work with case-sensitive languages
regularly take advantage of the flexibility that this case
sensitivity gives them when selecting variable names. Visual Basic
2009, however, is not case sensitive. The CLS works around this by
indicating that CLS-compliant code should not expose any two names
that differ only in their case. Therefore, Visual Basic 2009 code
can work with CLS-compliant code.
This example shows that the CLS works in two ways.
First, it means that individual compilers do not have to be
powerful enough to support the full features of .NET - this should
encourage the development of compilers for other programming
languages that target .NET. Second, it provides a guarantee that,
if you restrict your
classes to exposing only CLS-compliant features, code written in
any other compliant language can use your classes.
The beauty of this idea is that the restriction to
using CLS-compliant features applies only to public and protected
members of classes and public classes. Within the private
implementations of your classes, you can write whatever non-CLS
code you want, because code in other assemblies (units of managed
code, see later in this chapter) cannot access this part of your
code anyway.
We won’t go into the details of the CLS
specifications here. In general, the CLS won’t affect your C# code
very much, because there are very few non-CLS-compliant features of
C# anyway.
Garbage Collection
The garbage collector
is .NET’s answer to memory management, and in particular to the
question of what to do about reclaiming memory that running
applications ask for. Up until now, two techniques have been used
on the Windows platform for deallocating memory that processes have
dynamically requested from the system:
Having the application code responsible for
deallocating memory is the technique used by lower-level,
high-performance languages such as C++. It is efficient, and it has
the advantage that (in general) resources are never occupied for
longer than necessary. The big disadvantage, however, is the
frequency of bugs. Code that requests memory also should explicitly
inform the system when it no longer requires that memory. However,
it is easy to overlook this, resulting in memory leaks.
Although modern developer environments do provide
tools to assist in detecting memory leaks, they remain difficult
bugs to track down, because they have no effect until so much
memory has been leaked that Windows refuses to grant any more to
the process. By this point, the entire computer may have
appreciably slowed down due to the memory demands being made on
it.
Maintaining reference counts is favored in COM. The
idea is that each COM component maintains a count of how many
clients are currently maintaining references to it. When this count
falls to zero, the component can destroy itself and free up
associated memory and resources. The problem with this is that it
still relies on the good behavior of clients to notify the
component that they have finished with it. It only takes one client
not to do so, and the object sits in memory. In some ways, this is
a potentially more serious problem than a simple C++-style memory
leak, because the COM object may exist in its own process, which
means that it will never be removed by the system (at least with
C++ memory leaks, the system can reclaim all memory when the
process terminates).
The .NET runtime relies on the garbage collector
instead. This is a program whose purpose is to clean up memory. The
idea is that all dynamically requested memory is allocated on the
heap (that is true for all languages, although in the case of .NET,
the CLR maintains its own managed heap for .NET applications to
use). Every so often, when .NET detects that the managed heap for a
given process is becoming full and therefore needs tidying up, it
calls the garbage collector. The garbage collector runs through
variables currently in scope in your code, examining references to
objects stored on the heap to identify which ones are accessible
from your code - that is to say which objects have references that
refer to them. Any objects
that are not referred to are deemed to be no longer accessible from
your code and can therefore be removed. Java uses a system of
garbage collection similar to this.
Garbage collection works in .NET because IL has
been designed to facilitate the process. The principle requires
that you cannot get references to existing objects other than by
copying existing references and that IL be type safe. In this
context, what we mean is that if any reference to an object exists,
then there is sufficient information in the reference to exactly
determine the type of the object.
It would not be possible to use the garbage
collection mechanism with a language such as unmanaged C++, for
example, because C++ allows pointers to be freely cast between
types.
One important aspect of garbage collection is
that it is not deterministic. In other words, you cannot guarantee
when the garbage collector will be called; it will be called when
the CLR decides that it is needed (unless you explicitly call the
collector), though it is also possible to override this process and
call up the garbage collector in your code.
Security
.NET can really excel in terms of
complementing the security mechanisms provided by Windows because
it can offer code-based security, whereas Windows only really
offers role-based security.
Role-based security is
based on the identity of the account under which the process is
running (that is, who owns and is running the process). Code-based
security, on the other hand, is based on what the code actually
does and on how much the code is trusted. Thanks to the strong type
safety of IL, the CLR is able to inspect code before running it in
order to determine required security permissions. .NET also offers
a mechanism by which code can indicate in advance what security
permissions it will require to run.
The importance of code-based
security is that it reduces the risks associated with running
code of dubious origin (such as code that you’ve downloaded from
the Internet). For example, even if code is running under the
administrator account, it is possible to use code-based security to
indicate that that code should still not be permitted to perform
certain types of operation that the administrator account would
normally be allowed to do, such as read or write to environment
variables, read or write to the registry, or access the .NET
reflection features.
Security issues are covered in more depth in
Chapter 19, “.NET Security.”
Application Domains
Application domains are an important
innovation in .NET and are designed to ease the overhead involved
when running applications that need to be isolated from each other
but that also need to be able to communicate with each other. The
classic example of this is a Web server application, which may be
simultaneously responding to a number of browser requests. It will,
therefore, probably have a number of instances of the component
responsible for servicing those requests running
simultaneously.
In pre-.NET days, the choice would be between
allowing those instances to share a process, with the resultant
risk of a problem in one running instance bringing the whole Web
site down, or isolating those instances in separate processes, with
the associated performance overhead.
Up until now, the only means of isolating code has
been through processes. When you start a new application, it runs
within the context of a process. Windows isolates processes from
each other through address
spaces. The idea is that each process has available 4GB of virtual
memory in which to store its data and executable code (4GB is for
32-bit systems; 64-bit systems use more memory). Windows imposes an
extra level of indirection by which this virtual memory maps into a
particular area of actual physical memory or disk space. Each
process gets a different mapping, with no overlap between the
actual physical memories that the blocks of virtual address space
map to (see Figure
1-2).
In general, any process is able to access memory
only by specifying an address in virtual memory - processes do not
have direct access to physical memory. Hence it is simply
impossible for one process to access the memory allocated to
another process. This provides an excellent guarantee that any
badly behaved code will not be able to damage anything outside its
own address space. (Note that on Windows 95/98, these safeguards
are not quite as thorough as they are on Windows NT/2000/XP/2003,
so the theoretical possibility exists of applications crashing
Windows by writing to inappropriate memory.)
Processes don’t just serve as a way to isolate
instances of running code from each other. On Windows NT/
2000/XP/2003 systems, they also form the unit to which security
privileges and permissions are assigned. Each process has its own
security token, which indicates to Windows precisely what
operations that process is permitted to do.
Although processes are great for security reasons,
their big disadvantage is in the area of performance. Often, a
number of processes will actually be working together, and
therefore need to communicate with each other. The obvious example
of this is where a process calls up a COM component, which is an
executable and therefore is required to run in its own process. The
same thing happens in COM when surrogates are used. Because
processes cannot share any memory, a complex marshaling process has
to be used to copy data between the processes. This results in a
very significant performance hit. If you need components to work
together and don’t want that performance hit, then you have to use
DLL-based components and have everything running in the same
address space - with the associated risk that a badly behaved
component will bring everything else down.
Application domains are
designed as a way of separating components without resulting in the
performance problems associated with passing data between
processes. The idea is that any one process is divided into a number of application
domains. Each application domain roughly corresponds to a single
application, and each thread of execution will be running in a
particular application domain (see Figure 1-3).
If different executables are running in the same
process space, they are clearly able to easily share data, because
theoretically they can directly see each other’s data. However,
although this is possible in principle, the CLR makes sure that
this does not happen in practice by inspecting the code for each
running application, to ensure that the code cannot stray outside
its own data areas. This looks at first sight like an almost
impossible trick to pull off - after all, how can you tell what the
program is going to do without actually running it?
In fact, it is usually possible to do this because
of the strong type safety of the IL. In most cases, unless code is
using unsafe features such as pointers, the data types it is using
will ensure that memory is not accessed inappropriately. For
example, .NET array types perform bounds checking to ensure that no
out-of-bounds array operations are permitted. If a running
application does need to communicate or share data with other
applications running in different application domains, it must do
so by calling on .NET’s remoting services.
Code that has been verified to check that it
cannot access data outside its application domain (other than
through the explicit remoting mechanism) is said to be memory type safe. Such code can safely be run
along-side other type-safe code in different application domains
within the same process.
Error Handling with Exceptions
The .NET Framework is designed to facilitate
handling of error conditions using the same mechanism, based on
exceptions, that is employed by Java and C++. C++ developers should
note that because of IL’s stronger typing system, there is no
performance penalty associated with the use of exceptions with IL
in the way that there is in C++. Also, the finally block, which has long been on many C++
developers’ wish list, is supported by .NET and by C#.
Exceptions are covered in detail in Chapter
13, “Errors and Exceptions.” Briefly, the idea is that
certain areas of code are designated as exception handler routines,
with each one able to deal with a particular error condition (for example, a
file not being found, or being denied permission to perform some
operation). These conditions can be defined as narrowly or as
widely as you want. The exception architecture ensures that when an
error condition occurs, execution can immediately jump to the
exception handler routine that is most specifically geared to
handle the exception condition in question.
The architecture of exception handling also
provides a convenient means to pass an object containing precise
details of the exception condition to an exception handling
routine. This object might include an appropriate message for the
user and details of exactly where in the code the exception was
detected.
Most exception-handling architecture, including the
control of program flow when an exception occurs, is handled by the
high-level languages (C#, Visual Basic 2009, C++), and is not
supported by any special IL commands. C#, for example, handles
exceptions using try{}, catch{}, and finally{}
blocks of code. (For more details, see Chapter 13.)
What .NET does do, however, is provide the
infrastructure to allow compilers that target .NET to support
exception handling. In particular, it provides a set of .NET
classes that can represent the exceptions, and the language
interoperability to allow the thrown exception objects to be
interpreted by the exception-handling code, irrespective of what
language the exception-handling code is written in. This language
independence is absent from both the C++ and Java implementations
of exception handling, although it is present to a limited extent
in the COM mechanism for handling errors, which involves returning
error codes from methods and passing error objects around. The fact
that exceptions are handled consistently in different languages is
a crucial aspect of facilitating multilanguage development.
Use of Attributes
Attributes are a
feature that is familiar to developers who use C++ to write COM
components (through their use in Microsoft’s COM Interface
Definition Language [IDL]). The initial idea of an attribute was
that it provided extra information concerning some item in the
program that could be used by the compiler.
Attributes are supported in .NET - and hence now by
C++, C#, and Visual Basic 2009. What is, however, particularly
innovative about attributes in .NET is that a mechanism exists
whereby you can define your own custom attributes in your source
code. These user-defined attributes will be placed with the
metadata for the corresponding data types or methods. This can be
useful for documentation purposes, where they can be used in
conjunction with reflection technology in order to perform
programming tasks based on attributes. Also, in common with the
.NET philosophy of language independence, attributes can be defined
in source code in one language and read by code that is written in
another language.
Attributes are covered in Chapter
12, “Reflection.”
|