Athena.NET Interoperability: COM Interop

Brian Long (www.blong.com)

Table of Contents

Click here to download the files associated with this article.

If you find this article useful then please consider making a donation. It will be appreciated however big or small it might be and will encourage Brian to continue researching and writing about interesting subjects in the future.


Introduction

.NET is a new programming platform representing the future of Windows programming. Developers are moving across to it and learning the new .NET oriented languages and frameworks, but new systems do not appear overnight. It is a lengthy process moving entire applications across to a new platform and Microsoft is very much aware of this.

To this end, .NET supports a number of interoperability mechanisms that allow applications to be moved across from the Win32 platform to .NET piece by piece, allowing developers to still build complete applications, but which comprise of Win32 portions and some .NET portions, of varying amounts.

When building new .NET applications, there are provisions for using existing Win32 DLL exports (both custom DLL routines and standard Win32 API routines) as well as COM objects (which then act like any other .NET object).

When building Win32 applications there is a process that allows you to access individual routines in .NET assemblies. When building Win32 COM client applications, there is a mechanism that lets you use .NET objects as if they were normal COM objects.

This paper investigates the interoperability options that involve COM (generally described as COM Interop)

  • .NET code accessing unmanaged COM objects
  • Win32 COM applications accessing managed .NET objects

The accompanying paper, .NET Interoperability: .NET <-> Win32 (see Reference 1), looks at interoperability between .NET and Win32 that does not involve COM.

The coverage will have a specific bias towards a developer moving code from Delphi for Win32 to Delphi for .NET, however the principles apply to any other development tools. Clearly the Delphi-specific details will not apply to other languages but the high-level information will still be relevant. You can find the full story of COM Interop, using C# syntax mostly, in the book .NET and COM (see Reference 2).

Because of the different data types available on the two platforms (such as PChar in Win32 and the new Unicode String type on .NET), inter-platform calls will inevitably require some form of marshaling process to transform parameters and return values between the data types at either end of the call. Fortunately, as we shall see, the marshalling is done for us after an initial process to set up the inter-platform calls.

In a COM Interop system, there must be some form of reconciliation between the COM reference counting model and the .NET garbage collection model. Again, after the initial setup step, this is all taken care of for the developer by wrapper objects manufactured by the .NET support tools.

.NET Clients Using Win32 COM Server Objects (RCW)

You may start writing new .NET applications (or porting Win32 COM applications over to .NET) and need to access existing COM objects from within them. In order for .NET to use a COM object, wrapper objects called Runtime-Callable Wrappers (RCW objects) need to be generated. These wrapper objects cater for the difference in lifetime management between .NET and COM. RCW objects are .NET objects that manage the reference count of a COM object as well as dealing with the marshaling of parameters and return types for the COM object methods.

Interop Assemblies

RCW objects are manufactured at runtime by the CLR using information found in an Interop Assembly (an assembly containing definitions of COM types that can be used from managed code). You use a type library importer to scan the COM server type library and generate appropriate .NET-compatible information in an Interop Assembly for your COM server.

The type library importer can be invoked from a utility, Tlbimp.exe, that is supplied with the .NET Framework SDK. You can also do it under program control using the TypeLibConverter class in the System.Runtime.InteropServices namespace. However it is most common to have your IDE do the work for you, saving you from having to worry about the details.

Creating An Interop Assembly

To see the process thatís involved, let's make a simple example that uses the Microsoft Speech API 5.x (SAPI 5.x). Note that SAPI 4.0 is more widespread, but is completely incompatible with 5.x. You can download the full SDK for SAPI 5.1 from http://www.microsoft.com/speech/download/sdk51 but Windows XP has version 5.0 installed by default anyway, which should suffice. SAPI 5.x is implemented in an in-proc COM server called sapi.dll located in C:\Program Files\Common Files\Microsoft Shared\Speech. It has a type library bound into it as a custom resource, in the same way that Delphi COM servers do. To learn about programming the Microsoft Speech API, see Reference 5 and Reference 6.

To generate an Interop Assembly for the SAPI 5.x COM server you simply need to add a reference to its type library into your project references, in much the same way as adding a reference to a regular .NET assembly; by right-clicking on the References node in the Project Manager and choosing Add Reference, or by selecting Project | Add Reference... In the Add Reference dialog the .NET Assemblies tab is selected by default but the COM Imports tab allows you to locate type libraries.

All registered type libraries will be displayed, listed by the help strings (the Delphi for Win32 Type Library Editor allows you to set this for Delphi COM servers). If your COM server has not been registered, thereby meaning its type library has not been registered, you can still find it with the Browse... button.

Once a type library has been added, the References node of the Project Manager reflects this by listing the generated Interop Assembly.

Notice the name of the Interop Assembly follows a convention used by the popular IDEs. Delphi for .NET uses the Interop prefix convention, which seems descriptive enough for Interop Assemblies that describe custom COM servers. The other half of the assembly name is typically set to the library name, as found in the type library. If you open the type library in Delphi the top level node in the Type Library Editor tells you the library name. By default, Delphi-written COM servers use the project name, but this is not always the case as you can see with the SAPI type library; the COM server is called sapi.dll but the type library is called SpeechLib. Inside the Interop Assembly will be a namespace named after the type library, which will be SpeechLib in this case.

Whilst it is very convenient to have the IDE create your Interop Assembly, you can opt to create them yourself if you wish. This allows you the luxury of choosing your own arbitrary name for it. To generate an Interop Assembly use the TlbImp.exe utility like this:

 
C:\Temp>TlbImp "C:\Program Files\Common Files\Microsoft Shared\Speech\sapi.dll" /sysarray /namespace:SpeechLib /out:Interop.SpeechLib.dll
Microsoft (R) .NET Framework Type Library to Assembly Converter 1.1.4322.573
Copyright (C) Microsoft Corporation 1998-2002.All rights reserved.
 
TlbImp warning: At least one of the arguments for 'SpNotifyTranslator.InitCallback' can not be marshaled by the runtime marshaler.Such arguments will therefore be passed as a pointer and may require unsafe code to manipulate.
TlbImp warning: At least one of the arguments for 'SpNotifyTranslator.InitSpNotifyCallback' can not be marshaled by the runtime marshaler.Such arguments will therefore be passed as a pointer and may require unsafe code to manipulate.
TlbImp warning: At least one of the arguments for 'SpNotifyTranslator.InitWin32Event' can not be marshaled by the runtime marshaler.Such arguments will therefore be passed as a pointer and may require unsafe code to manipulate.
[Lots of similar warnings omitted]
TlbImp warning: The type library importer could not convert the signature for the member 'SPPHRASE.pProperties'.
TlbImp warning: The type library importer could not convert the signature for the member 'SPPHRASE.pElements'.
TlbImp warning: The type library importer could not convert the signature for the member 'SPPHRASE.pReplacements'.
[Lots of similar warnings omitted]
Type library imported to C:\Temp\Interop.SpeechLib.dll

Note: the type library importer makes certain assumptions about the use of parameters and sometimes will use parameter types that are not the most appropriate. The warnings in the output above suggest that in this case it could not even decide how to deal with the marshaling requirements in cases. You can modify the results of the import process using creative round tripping against the Interop Assembly. The technique of creative round tripping is discussed in Reference 1 and Reference 3. You can also find mention of it, along with advice on how to resolve common Interop Assembly errors in Reference 4.

Note: in order to call utilities supplied with the .NET Framework SDK you must have the appropriate paths set up. This is not done on a global scale by the installation process, but a handy batch file is supplied that sets it up for you. The batch file is called sdkvars.bat and by default is located in C:\Program Files\Microsoft.NET\SDK\v1.1\Bin. To make it easy to invoke this, you can add a new shortcut to a Start Menu group, or to your desktop and set the command to be:

cmd /K "C:\Program Files\Microsoft.NET\SDK\v1.1\bin\SDKVars.bat"

This will launch a command prompt, run the batch file, and leave the command prompt open (as opposed to closing it, which it would unhelpfully do by default).

Using An Interop Assembly

Now that we have an Interop Assembly we can use it. Here is the most trivial SAPI application we can test out, which makes use of the COM object that implements the SpVoice interface.

 
uses
SpeechLib;
...
procedure TSpeechForm.speakButton_Click(sender: System.Object; e: System.EventArgs);
var
Voice: SpVoice;
begin
Voice := SpVoiceClass.Create;
Voice.Speak('Hello world', SpeechVoiceSpeakFlags.SVSFDefault);
end;

This is enough to have the computer speak to you in its default voice. You can take it slightly further by adding a text box onto the form and passing its Text property to the Speak method instead. The screenshot below shows some text that contains XML tags, which are used to alter attributes of the spoken text.

Note: that the Interop Assembly must be accessible to the .NET application when running. This means you can place a copy of it in the application directory or install it in the GAC, however it must be strong named to do the latter. When generating an Interop Assembly using TlbImp.exe, you can use the /keyfile option to specify a strong name key file for this purpose.

When Delphi generates an Interop Assembly it places it in a subdirectory of your project directory called ComImports, to keep it isolated. However, as just noted, the assembly needs to be accessible in order for a referencing application to use it. To deal with this the assembly has a special property set. You may note in Delphi for .NET that the Object Inspector shows properties for more things than just components on forms now.

If you select a referenced assembly in the Project Manager, various properties will be displayed in the Object Inspector. One option is Copy Local. For an assembly registered in the GAC this property will be False as the assembly will be found at runtime. However for Interop Assemblies created by the IDE this property is set to True. When you run the application any referenced assemblies with Copy Local set to True will be copied into the applicationís output directory (wherever the .EXE is set to be created). If the assembly already exists in that directory it will be overwritten to ensure you have the most up-to-date version.

Early Binding

The most common requirement will be to use early binding to get compile-time type checking and direct (well, as direct as it gets) vtable calls to the COM objects. The example above took this approach. As long as you know the namespace in the Interop Assembly and the types you need to define, you should find the Code Completion will answer questions about which methods can be called and which parameters need to be passed.

The basic rule is that for an exposed coclass Foo you must create an instance of the RCW class FooClass. Interfaces carry through into the Interop Assembly with their names unchanged. However you should know that an Interop Assembly will automatically create another interface for each original coclass Foo called Foo. This interface will support the coclassís default interface as well as a wrapper for the default source interface (events interface). In the case of the original SAPI coclass SpVoice, its default interface is ISpeechVoice and its events interface is _ISpeechVoiceEvents. In the Interop Assembly the coclass is represented by the RCW SpVoiceClass but it will return a SpVoice interface, where SpVoice is a combination of ISpeechVoice and _ISpeechVoiceEvents_Event. The latter interface is a helper interface to make it easy to set up event handlers with .NET code as we shall see later.

Late Binding

You can also perform late binding using the .NET reflection APIs, which do not require the Interop Assembly to be present. Late binding is supported on COM objects implementing IDispatch (i.e. Automation objects) and operates by calling IDispatch.Invoke. For simple pass-by-value parameters, things are quite straightforward: you set up a System.Type (or &Type) reflection object to represent a class that maps onto the ProgID, ask the Activator object to instantiate the referenced class, then access the methods through the Type object's InvokeMember method. Arguments are passed as objects in arrays and the result (if any) is returned in an object.

 
uses
System.Reflection;
...
procedure TSpeechForm.speakButton_Click(sender: System.Object; e: System.EventArgs);
var
VoiceType: &Type;
Voice: &Object;
TwoArgs: array[1..2] of &Object;
begin
VoiceType := &Type.GetTypeFromProgID('SAPI.SpVoice');
Voice := Activator.CreateInstance(VoiceType);
// Set up a call to a method with two value parameters
TwoArgs[1] := speechTextBox.Text;
TwoArgs[2] := 0; // SpeechVoiceSpeakFlags.SVSFDefault
VoiceType.InvokeMember('Speak', BindingFlags.InvokeMethod, nil, Voice, TwoArgs);
end;

Passing a parameter by reference is more tedious. You must call an overloaded version of InvokeMember, passing an array containing a single ParameterModifier object. This object's constructor takes a parameter specifying how many arguments the appropriate Automation method takes. This causes it to allocate an internal Boolean array with that many elements, which is exposed by the default array property, Item (meaning you can omit it, if you desire). Before invoking the member you must loop across each argument, specifying whether it is to be passed by reference or value by assigning True or False, respectively, to the corresponding Item array element.

The Speech API doesnít make much use of reference parameters, but I found an example method that does. SAPI can display certain dialog boxes (or occasionally windows) if the underlying voice or recognition engines support them. These dialogs are identified by well known strings that can be found in the SAPI 5.1 SDK documentation. To invoke a dialog you should check whether it is supported using the IsUISupported method. If this reports back positively then you can display it with DisplayUI. This pair of methods takes various value parameters but also take a reference parameter which in Win32 is a Variant but becomes an Object in .NET.

The idea is that some dialogs make require additional information to be passed in and can also pass information back, so the parameter is a reference parameter. As it happens, none of the SAPI dialogs make use of this information, but a value still needs to be passed. Indeed this parameter is optional under Win32 Automation, and so EmptyParam would do just fine in Delphi for Win32 if using interfaces to call these methods in an early bound manner. In .NET the equivalent to EmptyParam is &Type.Missing (System.Type.Missing) or Reflection.Missing (System.Reflection.Missing).

To see how reference parameters are dealt with, consider this early bound code that invokes the volume control window (ultimately the same as manually running the sndvol32.exe Windows accessory).

 
uses
SpeechLib;
...
procedure TSpeechForm.volumeButton_Click(sender: System.Object; e: System.EventArgs);
var
Voice: SpVoice;
AddlData: &Object;
const
uiName = 'AudioVolume';
begin
Voice := SpVoiceClass.Create;
AddlData := nil;
if Voice.IsUISupported(uiName, AddlData) then
††† Voice.DisplayUI(Handle.ToInt32, '', uiName, AddlData)
else
††† MessageBox.Show('No volume control support', 'Warning',
††††† MessageBoxButtons.OK, messageBoxIcon.Warning)
end;

This uses both methods, so uses two reference parameters in total. To rework this as late bound code requires it to be rewritten like this:

 
uses
System.Reflection;
...
procedure TSpeechForm.volumeButton_Click(sender: System.Object; e: System.EventArgs);
var
VoiceType: &Type;
Voice, AddlData: &Object;
TwoArgs: array[1..2] of &Object;
FourArgs: array[1..4] of &Object;
ParamModifiers: array[0..0] of ParameterModifier;
const
uiName = 'AudioVolume';
begin
VoiceType := &Type.GetTypeFromProgID('SAPI.SpVoice');
Voice := Activator.CreateInstance(VoiceType);
AddlData := &Type.Missing;
// Set up call with 1 value parameter and 1 reference parameter
TwoArgs[1] := uiName;
TwoArgs[2] := AddlData; // passed by reference
ParamModifiers[0] := ParameterModifier.Create(2);
ParamModifiers[0][0] := False; // 1st arg is by value
ParamModifiers[0][1] := True;// 2nd arg is by reference
if Boolean(VoiceType.InvokeMember('IsUISupported',
††† BindingFlags.InvokeMethod, nil, Voice, TwoArgs)) then
begin
††† // Set up call with 3 value parameters and 1 reference parameter
††† FourArgs[1] := Handle;
††† FourArgs[2] := '';
††† FourArgs[3] := uiName;
††† FourArgs[4] := AddlData; // passed by reference
††† ParamModifiers[0] := ParameterModifier.Create(4);
††† ParamModifiers[0][0] := False; // 1st arg is by value
††† ParamModifiers[0][1] := False; // 2nd arg is by value
††† ParamModifiers[0][2] := False; // 3rd arg is by value
††† ParamModifiers[0][3] := True;// 4th arg is by reference
††† VoiceType.InvokeMember('DisplayUI',
††††† BindingFlags.InvokeMethod, nil, Voice, FourArgs)
end
else
††† MessageBox.Show('No volume control support', 'Warning',
††††† MessageBoxButtons.OK, messageBoxIcon.Warning)
end;

Hooking COM Server Events

Setting up event handlers for COM object events is much the same as for any other object, as the Interop Assembly defines delegate types for all event methods, and implements helpful add and remove routines in the events interface wrapper. In the Speech API case mentioned above the real events interface is called _ISpeechVoiceEvents but the Interop Assembly defines a wrapper interface called _ISpeechVoiceEvents_Event.

All you need to know in order to write event handlers for COM events you need to know about the delegate types present in the Interop Assembly. Once you have the information it is simply a case of implementing a compatible routine and using Include to add it to the appropriate event. Using Include adds your handler to the potential list of other handlers; .NET events, as implemented by delegates, support event handler multiplexing. The add/remove routines in the generated wrapper interface are used to insert/remove your handler in/from the list when using Include/Exclude.

Letís look at an example where we need to implement event handlers for the following events: Word (triggered as each separate word is about to be spoken), EndStream (triggered when the speech stream has been fully processed) and AudioLevel (triggered as the voice amplitude changes). To find the event handler signatures we need to examine the delegate types in the Interop Assembly. Remember that what looks like a regular procedural type (or event handler type) definition in Delphi for .NET is translated to a .NET delegate type. This means that each type will be implemented in the Interop Assembly as a class that inherits from System.MulticastDelegate. The signature is indicated by the parameter list that the delegate classís Invoke method takes. The event handlers we implement will be procedure methods taking the same parameter list.

We can use the .NET Framework SDK IL Disassembler utility (ildasm.exe) to examine the delegate types (see note above regarding using the SDK tools. In the original type library the events interface was called _ISpeechVoiceEvents and the pertinent methods are called Word, EndStream and AudioLevel. When the type library importer manufactures the Interop Assembly it creates delegate types with names in the form: SourceInterfaceName_MethodNameEventHandler so the specific delegate types we need will therefore be called _ISpeechVoiceEvents_WordEventHandler, _ISpeechVoiceEvents_EndStreamEventHandler and _ISpeechVoiceEvents_AudioLevelEventHandler. If you locate one of these delegates in ILDasm you can see the class inheritance and the presence of the custom Invoke method.

Double-clicking the method shows more detail on its makeup, showing the parameter names (which admittedly are irrelevant; itís the types that are crucial).

From this we could reconstruct a suitable Delphi type definition to base the event handler methods on:

type
_ISpeechVoiceEvents_AudioLevelEventHandler = procedure(StreamNumber: Integer; StreamPosition: &Object; AudioLevel: Integer);

However there is a certain amount of manual translation required here. It can be simpler to use the popular Reflector tool from Lutz Roeder, available free from http://www.aisto.com/roeder/dotnet. This tool can decompile IL code in assemblies to C#, Visual Basic.NET and also Delphi for .NET. Additionally delegate classes are rendered in local language form, which trivialises the process of identifying the method signature:

The Delphi representation of the delegate even goes to the trouble of including any attributes that have been applied to the delegate or its parameters. The example in the screenshot above in full looks like this:

 
[ComVisible(false), TypeLibType(16)]

††
††
††
††public _ISpeechRecoContextEvents_EndStreamEventHandler = procedure Invoke( 
[In] StreamNumber: Integer;
[In, MarshalAs(UnmanagedType.Struct)] StreamPosition: TObject;
[In] StreamReleased: boolean);

The event handler methods that we need will look like this:

 
procedure VoiceEndStream(StreamNumber: Integer; StreamPosition: &Object);
procedure VoiceAudioLevel(StreamNumber: Integer; StreamPosition: &Object; AudioLevel: Integer);
procedure VoiceWord(StreamNumber: Integer; StreamPosition: &Object; CharacterPosition, Length: Integer);

In order to have the events trigger we need to assign a value to the voice objectís EventInterests parameter. The catch-all value of SpeechVoiceEvents.SVEAllEvents causes all events to fire, but the SpeechVoiceEvents enumerated type acts as a bitmask type, so you can add them together (or combine them with the or operator), with appropriate casting:

 
constructor TSpeechForm.Create;
begin
...
Voice := SpVoiceClass.Create;
Include(Voice.Word, VoiceWord);
Include(Voice.EndStream, VoiceEndStream);
Include(Voice.AudioLevel, VoiceAudioLevel);
 
// Get all events to fire
// Voice.EventInterests := SpeechVoiceEvents.SVEAllEvents
 
// Get required events to fire
Voice.EventInterests :=
††† SpeechVoiceEvents(
††††† Ord(SpeechVoiceEvents.SVEWordBoundary) or
††††† Ord(SpeechVoiceEvents.SVEEndInputStream) or
††††† Ord(SpeechVoiceEvents.SVEAudioLevel))
end;

We can use these event handlers to write a simplistic animated text reader. The text to read comes from a text box and the Word event handler highlights each word in the text box (speechTextBox) as it is spoken. The EndStream event handler can remove any remaining highlight and the AudioLevel event handler can control a progress bar (audioProgressBar) being used as a VU meter for the speech:

 
procedure TSpeechForm.VoiceAudioLevel(StreamNumber: Integer;
StreamPosition: &Object; AudioLevel: Integer);
begin
audioProgressBar.Value := AudioLevel
end;
 
procedure TSpeechForm.VoiceEndStream(StreamNumber: Integer;
StreamPosition: &Object);
begin
//Reset VU meter
audioProgressBar.Value := 0;
//Highlight word being spoken in the text box
speechTextBox.SelectionLength := 0;
speechTextBox.SelectionStart := Length(speechTextBox.Text)
end;
 
procedure TSpeechForm.VoiceWord(StreamNumber: Integer;
StreamPosition: &Object; CharacterPosition, Length: Integer);
begin
speechTextBox.SelectionStart := CharacterPosition;
speechTextBox.SelectionLength := Length //highlight word
end;

Win32 COM Clients Using .NET Objects (CCW)

You may start writing new .NET objects (or porting Win32 COM objects over to .NET) and need to access these new objects from your existing Win32 COM client applications. In order for a COM client application to use a .NET object, wrapper objects called COM-Callable Wrappers (CCW objects) need to be generated. These wrapper objects cater for the difference in lifetime management between COM and .NET. CCW objects are COM objects that reconcile the reference counting of COM against the garbage collection of .NET as well as dealing with the marshaling of parameters and return types for the .NET object methods.

Registering A .NET Assembly for COM

CCW objects are manufactured at runtime by the CLR via class factories that are created when the .NET assembly is accessed by a COM client. This requires the assembly to be registered as a normal COM server would be.

The assembly registration can be performed from a utility, Regasm.exe, that is supplied with the .NET Framework (and the SDK). You can also do it under program control using the RegistrationServices class in the System.Runtime.InteropServices namespace, although use of the utility program is much more common.

To see the process, let's consider a .NET assembly, called dotNetAssembly.dll, that contains a class DotNetObject:

 
type
DotNetObject = class(&Object)
 public
††† constructor Create;
††† procedure One(const Msg: String);
††† function Two(Input: Integer; var Output: Integer): Boolean;
end;

To register the assembly, the RegAsm.exe command can be used:

C:\Temp>Regasm dotNetAssembly.dll
Microsoft (R) .NET Framework Assembly Registration Utility 1.1.4322.573
Copyright (C) Microsoft Corporation 1998-2002.All rights reserved.
 
Types registered successfully

and for each class found in the assembly adds these entries to the registry:

 
HKCR\ProgID\(Default)="NamespaceQualifiedClassName"
HKCR\ProgID\CLSID\(Default)="{CLSID}"
HKCR\CLSID\{CLSID}\(Default)="NamespaceQualifiedClassName"
HKCR\CLSID\{CLSID}\InprocServer32\(Default)="WindowsSystemDirectory\mscoree.dll"
HKCR\CLSID\{CLSID}\InprocServer32\ThreadingModel="Both"
HKCR\CLSID\{CLSID}\InprocServer32\Class="NamespaceQualifiedClassName"
HKCR\CLSID\{CLSID}\InprocServer32\Assembly="FullAssemblyName"
HKCR\CLSID\{CLSID}\InprocServer32\RuntimeVersion="Version"
HKCR\CLSID\{CLSID}\ProgId\(Default)="ProgID"

Where:

  • NamespaceQualifiedClassName in this example is: dotNetAssembly.DotNetObject
  • ProgID is typically the same string as NamespaceQualifiedClassName
  • CLSID is the Class ID (GUID) generated for the CCW, e.g.:
    697D799D-5447-35A0-ABFA-9A5B15630724
  • FullAssemblyName, as the name suggests is the full assembly name, such as this for this unsigned example: dotNetAssembly, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null
  • WindowsSystemDirectory is typically C:\WINDOWS\System32
  • Version is the .NET version (currently v1.1.4322), which identifies where the .NET Framework resides, e.g. C:\WINDOWS\Microsoft.NET\Framework\v1.1.4322

Note that the assembly must be placed appropriately for the CLR to find it. This means you should install it in the GAC or place it in the application directory. If you wish to leave the assembly elsewhere (during development) you can do this as long as you specify the /codebase option when invoking regasm.exe. This option causes an additional entry to be added to the registry for each registered coclass specifying the assembly location using a fully qualified file name.

We can now proceed to use late binding against the .NET object (we'll see an example later), but clearly it is more typical for COM applications to use early binding. Early binding to a .NET object can be achieved by generating a type library for the .NET object, which is registered along with the assembly.

Interop Type Libraries

CCW objects (or exported classes) can be described in an Interop Type Library (a type library manufactured to contain COM type definitions that match the .NET metadata type definitions). You use a type library exporter to scan the .NET assembly and generate an Interop Type Library (also referred to as an exported type library).

The type library exporter can be invoked from a utility, Tlbexp.exe, that is supplied with the .NET Framework SDK, however this utility simply generates a type library and does nothing with it. It can also be invoked through the Regasm.exe utility, already described. Finally you can also do it under program control using the TypeLibConverter class in the System.Runtime.InteropServices namespace, although use of the Regasm.exe utility program is much more common.

Creating An Interop Type Library

To generate an Interop Assembly use RegAsm.exe like this:

 
C:\Temp>regasm dotNetAssembly.dll /tlb
Microsoft (R) .NET Framework Assembly Registration Utility 1.1.4322.573
Copyright (C) Microsoft Corporation 1998-2002.All rights reserved.
 
Types registered successfully
Type library exporter warning processing 'Borland.Delphi.System.TMethod.Code, dotNetAssembly'.Warning: Reference types in structs need [MarshalAs(UnmanagedType.Interface)].
Type library exporter warning processing 'Borland.Delphi.System.Currency.FValue, dotNetAssembly'.Warning: The public struct contains one or more non-public fields.
Type library exporter warning processing 'Borland.Delphi.System.TDateTime.FValue, dotNetAssembly'.Warning: The public struct contains one or more non-public fields.
Assembly exported to 'C:\Temp\dotNetAssembly.tlb', and the type library was registered successfully

Note: As you can see the Borland RTL code contains some items that worry the type library exporter, but weíll address that soon.

The /verbose command-line switch tells you a little more about the classes that have been described in the Interop Type Library (it offers no additional information when simply registering an assembly on its own) and any referenced assemblies that require a type library to be generated, so:

 
C:\Temp>regasm dotNetAssembly.dll /tlb /verbose
Microsoft (R) .NET Framework Assembly Registration Utility 1.1.4322.573
Copyright (C) Microsoft Corporation 1998-2002.All rights reserved.
 
Types registered successfully
Type IProxySystemSupport exported.
Type @TClass exported.
Type TAliasTypeAttribute exported.
Type TClass exported.
Type TAliasTypeBase exported.
Type TClassHelperAttribute exported.
Type TClassHelperBase exported.
Type IFreeNotify exported.
Type TVCLFreeNotify exported.
Type TMethodAttribute exported.
Type TVCLGetClassName exported.
Type MessageMethodAttribute exported.
Type _FinalizeHandler exported.
Type TSubrangeAttribute exported.
Type TUInt32SubrangeAttribute exported.
Type TInt64SubrangeAttribute exported.
Type TUInt64SubrangeAttribute exported.
Type ShortString exported.
Type TInt32SubrangeAttribute exported.
Type AnsiString exported.
Type TResourceKeyStringAttribute exported.
Type TVCLInitLocaleOverride exported.
Type RuntimeRequiredAttribute exported.
Type EMethodError exported.
Type EMethodMultiError exported.
Type EMethodInvokeError exported.
Type EMethodTypeMismatchError exported.
Type library exporter warning processing 'Borland.Delphi.System.TMethod.Code, dotNetAssembly'.Warning: Reference types in structs need [MarshalAs(UnmanagedType.Interface)].
Type TMethod exported.
Type TAliasAttribute exported.
Type IInterface exported.
Type TUniqueTypeModifier exported.
Type TSetElementTypeAttribute exported.
Type TClassRefAttribute exported.
Type TIdentifierAttribute exported.
Type TShortStringAttribute exported.
Type TConstantParamAttribute exported.
Type TPackageAttribute exported.
Type Extended exported.
Type library exporter warning processing 'Borland.Delphi.System.Currency.FValue, dotNetAssembly'.Warning: The public struct contains one or more non-public fields.
Type Currency exported.
Type ERangeError exported.
Type library exporter warning processing 'Borland.Delphi.System.TDateTime.FValue, dotNetAssembly'.Warning: The public struct contains one or more non-public fields.
Type TDateTime exported.
Type EConvertError exported.
Type EUnderflow exported.
Type EAssertionFailed exported.
Type Text exported.
Type ITextDeviceFactory exported.
Type TextOutput exported.
Type TextInput exported.
Type TextErrOutput exported.
Type ERuntimeError exported.
Type EFileNotOpenForInput exported.
Type EFileNotOpenForOutput exported.
Type EFileNotFound exported.
Type EInvalidFilename exported.
Type ETooManyOpenFiles exported.
Type EAccessDenied exported.
Type EEndOfFile exported.
Type EDiskFull exported.
Type EInvalidInput exported.
Type EClassDelegatorError exported.
Type DotNetObject exported.
Type AnsiChar exported.
Type NativeInt exported.
Type NativeUInt exported.
Type ByteBool exported.
Type WordBool exported.
Type LongBool exported.
Type TPackageFlag exported.
Type TPackageFlags exported.
Type TTextLineBreakStyle exported.
Type Unit exported.
Type Unit exported.
Assembly exported to 'C:\Temp\dotNetAssembly.tlb', and the type library was registered successfully

Note: in the case of a Delphi for .NET assembly, there are a whole range of types found that are generated by the compiler, or part of the RTL, that are not useful to the COM client. You can see many of these in the regasm.exe output above.

For each type found in the assembly, the following entries are added to the registry:

 
HKCR\ProgID\(Default)="NamespaceQualifiedClassName"
HKCR\ProgID\CLSID\(Default)="{CLSID}"
HKCR\CLSID\{CLSID}\(Default)="NamespaceQualifiedClassName"
HKCR\CLSID\{CLSID}\InprocServer32\(Default)="WindowsSystemDirectory\mscoree.dll"
HKCR\CLSID\{CLSID}\InprocServer32\ThreadingModel="Both"
HKCR\CLSID\{CLSID}\InprocServer32\Class="NamespaceQualifiedClassName"
HKCR\CLSID\{CLSID}\InprocServer32\Assembly="FullAssemblyName"
HKCR\CLSID\{CLSID}\InprocServer32\RuntimeVersion="Version"
HKCR\CLSID\{CLSID}\ProgId\(Default)="ProgID"
HKCR\CLSID\{CLSID}\Implemented Categories\{62C8FE65-4EBB-45e7-B440-6E39B2CDBF29}

It also registers the type library under HKCR\Typelib\{LIBID} and each exposed interface under HKCR\Interface\{IID}.

What's In An Interop Type Library?

The type library exporter exposes items that are deemed appropriate to be made available to COM, namely all visible classes, records and interfaces. You can use ComVisibleAttribute to hide things from the COM world, such as record types and interfaces:

 
[ComVisible(False)]
Definition of something not to be exposed through the Interop Type Library

Quite importantly you can employ this attribute on an assembly-wide basis to hide everything from COM by default, then specify the items you wish to be made visible to COM clients. This approach works well with Delphi-generated assemblies:

 
[assembly: ComVisible(False)]
 
[ComVisible(True)]
Definition of something to be exposed through the Interop Type Library

This is used for various reasons. For example, if you are creating a .NET object that implements an existing COM interface, you would define the interface in the assembly to mirror the existing COM definition. However there would be no point in making this new definition of the interface available so it would be marked as hidden. Applying it to our sample assembly yields this more apt verbose output:

 
C:\Temp>regasm dotNetAssembly.dll /tlb /verbose
Microsoft (R) .NET Framework Assembly Registration Utility 1.1.4322.573
Copyright (C) Microsoft Corporation 1998-2002.All rights reserved.
 
Types registered successfully
Type DotNetObject exported.
Assembly exported to 'C:\Temp\dotNetAssembly.tlb', and the type library was registered successfully

In addition, ComImportAttribute can be used specifically to hide interfaces, instead of ComVisibleAttribute, e.g.

 
[ComImport]
Definition of interface not to be exposed through the Interop Type Library

These attributes are used on many of the standard .NET Framework assemblies meaning that, for example, you cannot create a .NET form from a Win32 COM application - the Form class in the System.Windows.Forms namespace is not exposed. Indeed this assembly only exposes a small portion of its wares, and other assemblies hide everything from direct use through COM.

.NET Interfaces

When a .NET class is exposed to a COM client the ClassInterfaceAttribute (from the System.Runtime.InteropServices namespace) controls what interface (if any) will automatically be created by the type library export process. This attribute has three values and the default value is not necessarily the best choice (certainly not if you want to do early binding). The sample class above implicitly uses the default value and so we should understand the attribute values to see what choices we have.

  • ClassInterfaceType.AutoDispatch is the default value and causes an IDispatch-based interface to be generated with no members. This means that it enforces clients to use late-bound Automation. For a class Foo, a coclass Foo will be generated implementing a memberless interface _Foo as well as _Object, a .NET interface that represents the members of the base class System.Object.
  • ClassInterfaceType.AutoDual causes an IDispatch-based dual interface to be generated with members defined for all the public methods available in the class (all public class members). This interface supports early binding and late binding. For a class Foo, a coclass Foo will be generated implementing an interface _Foo as well as _Object.
  • ClassInterfaceType.None means that no class interface is automatically generated and again forces use of late bound Automation to access the class. For a class Foo, a coclass Foo will be generated that only implements _Object.

Because of the default behaviour of the attribute, if your class does not implement an interface, you might wish to specify the ClassInterfaceType.AutoDual attribute value to ensure you can use early binding against it, however Microsoft advises against this option as it may cause versioning problems for the COM clients if the .NET classes get modified:

 
type
//Generate a class interface that support early and late binding
[ClassInterface(ClassInterfaceType.AutoDual)]
DotNetObject = class(TObject)
public
††† constructor Create;
††† procedure One(const Msg: String);
††† function Two(Input: Integer; var Output: Integer): Boolean;
end;

Before we move on it should be made clear that defining an interface in .NET is an option you should consider carefully, indeed it is positively encouraged. If you want to define an interface for your class to implement you are at liberty to do so (and indeed there are benefits from doing so, not least of which is the control you get over multiple versions of the class). For example we could have an interface, IDotNetInterface, which defines the behaviour that will be made available through the DotNetObject class:

 
[assembly: ComVisible(False)]
 
type
[ComVisible(True)]
IDotNetInterface = interface(IInterface)
['{3C32D881-43DA-40D2-A7F6-0AE830C2920F}']
††† procedure One(const Msg: String);
††† function Two(Input: Integer; var Output: Integer): Boolean;
end;
 
[ComVisible(True)]
// Don't generate a class interface, since we already have a real interface
// Just generate a coclass
[ClassInterface(ClassInterfaceType.None)]
DotNetObject = class(TObject, IDotNetInterface)
public
††† constructor Create;
††† procedure One(const Msg: String);
††† function Two(Input: Integer; var Output: Integer): Boolean;
end;

Notice that in this case the ClassInterfaceAttribute has been used to tell the type library exporter not to create an interface that represents the class functionality; the auto-generated interface is no longer necessary since we now have a real interface designed to do just that.

When regasm.exe is run against the new assembly it will only expose IDotNetInterface:

 
C:\Temp>regasm dotNetAssembly.dll /tlb /verbose
Microsoft (R) .NET Framework Assembly Registration Utility 1.1.4322.573
Copyright (C) Microsoft Corporation 1998-2002.All rights reserved.
 
Types registered successfully
Type IDotNetInterface exported.
Assembly exported to 'C:\Temp\dotNetAssembly.tlb', and the type library was registered successfully

Another noteworthy point is that you can specify your own IID for the interface, to be used when exposed to COM clients. The IID can be specified either with traditional Delphi syntax:

 
IDotNetInterface = interface(IInterface)
['{3C32D881-43DA-40D2-A7F6-0AE830C2920F}']
...
end;

or with the GuidAttribute class:

 
[Guid('3C32D881-43DA-40D2-A7F6-0AE830C2920F')]
IDotNetInterface = interface(IInterface)
...
end;

Note: Unlike in Delphi 8 for .NET, the traditional Delphi syntax is respected and applied, so you can choose either way of expressing your IID.

Using An Interop Type Library

Now we have a type library describing the .NET objects (or the CCWs for them) it can be imported into a Win32 Delphi project. In Delphi 7 you use Project | Import Type Library...:

However in Delphi "Diamondback" you select Component | Import Component..., ensure Import a Type Library is selected, then press Next to browse the registered type libraries.

Note: In the pre-release version of Delphi "Diamondback" used to write this paper, the functionality to import type libraries into Win32 projects was not fully enabled and so screenshots and full descriptions are not possible, and the details below may vary from what is actually required in the shipping product version. It is assumed this functionality will be fully active in the release product. If need be the Delphi command-line utility tlibimp.exe can be used to created the type library import unit. Either way, an updated version of this article will appear on the Articles page of my Web site containing all the pertinent information about type library importing shortly after Delphi "Diamondback" is released.

Assuming the Generate Component Wrappers checkbox is checked then this dialog/wizard will import the type library, generate definitions for the pertinent interfaces and coclasses and also create wrapper components for the coclasses. Press the Install... button, choose a design-time package to install the unit into the IDE through, then let the package be compiled and installed. You can now either use the component wrappers on the Component Palette, or programmatically refer to the items exposed through the type library.

Early Binding

To use early binding against the CCW you treat it like a normal COM object. This means you can use the CreateComObject routine from the ComObj unit or use the creator object defined in the type library import unit (CoXXXX where XXXX is the coclass name):

 
procedure TfrmCCW.btnEarlyBoundClick(Sender: TObject);
var
DotNetObject: IDotNetInterface;
Output: Integer;
begin
DotNetObject := CoDotNetObject.Create;
DotNetObject.One('Hello World!!!');
if DotNetObject.Two(Input, Output) then
††† ShowMessageFmt('The answer to %d is %d', [Input, Output])
end;

Remember that if the .NET class has no real interface defined, then you will only be able to do early binding if the ClassInterfaceAttribute is set to ClassInterface.AutoDual. In this case the code would need to be written slightly differently to cater for the name of the class interface:

 
procedure TfrmCCW.btnEarlyBoundClick(Sender: TObject);
var
DotNetObject: _DotNetObject;
Output: Integer;
begin
DotNetObject := CoDotNetObject.Create;
DotNetObject.One('Hello World!!!');
if DotNetObject.Two(Input, Output) then
††† ShowMessageFmt('The answer to %d is %d', [Input, Output])
end;

Late Binding

You can perform Variant-based late bound Automation against a .NET class so long as you register the assembly with regasm.exe. Whether you generate an Interop Type Library, and whether the class has a specific interface is irrelevant.

 
procedure TfrmCCW.btnLateBoundClick(Sender: TObject);
var
DotNetObject: Variant;
Output: Integer;
begin
DotNetObject := CreateOleObject('dotNetAssembly.DotNetObject');
DotNetObject.One('Hello world!!!');
if DotNetObject.Two(Input, Output) then
††† ShowMessageFmt('The answer to %d is %d', [Input, Output])
end;

If you have an Interop Type Library you can also use late bound Automation against the dispinterface in the type library for slightly better performance (but not as good as the performance obtained through early binding). If you implement a specific interface the code looks like this:

 
procedure TfrmCCW.btnMediumBoundClick(Sender: TObject);
var
DotNetObject: IDotNetInterfaceDisp;
Output: Integer;
begin
DotNetObject := CreateOleObject('dotNetAssembly.DotNetObject') as IDotNetInterfaceDisp;
DotNetObject.One('Hello World!!!');
if DotNetObject.Two(Input, Output) then
††† ShowMessageFmt('The answer to %d is %d', [Input, Output])
end;

If not, and the ClassInterface.AutoDual attribute value was specified, the code looks like this:

 
procedure TfrmCCW.btnMediumBoundClick(Sender: TObject);
var
DotNetObject: _DotNetObjectDisp;
Output: Integer;
begin
DotNetObject := CreateOleObject('dotNetAssembly.DotNetObject') as _DotNetObjectDisp;
DotNetObject.One('Hello World!!!');
if DotNetObject.Two(Input, Output) then
††† ShowMessageFmt('The answer to %d is %d', [Input, Output])
end;

Summary

This paper has looked at the COM Interop mechanism that facilitates building Windows systems out of COM and .NET components. This will continue to be a useful technique whilst .NET is still at an early stage of its life and Win32 dominates in terms of existing systems and developer skills. Indeed, due to the nature of legacy code this may continue long after .NET programming dominates the Windows arena.

The coverage of COM Interop has been intended to be complete enough to get you started without having too many unanswered questions. However it is inevitable in a paper of this size that much information has been omitted. The references below should provide much of the information that could not be fitted into this paper.

References

  1. .NET Interoperability: .NET Win32 by Brian Long.
    This paper looks at the issues involved in .NET code using routines in Win32 DLLs (including Win32 API routines) using the PInvoke mechanism, and also Win32 applications accessing routines in .NET assemblies using the Inverse PInvoke mechanism.
  2. .NET and COM, The Complete Interoperability Guide by Adam Nathan (of Microsoft), SAMS.
    This covers everything you will need to know about interoperability between .NET and COM, plus lots more you won't ever need.
  3. Inside Microsoft .NET IL Assembler by Serge Lidin (of Microsoft), Microsoft Press.
    This book describes the CIL (Common Intermediate Language) in detail and is the only text I've seen that shows how to export .NET assembly methods for Win32 clients. The author was responsible for developing the IL Disassembler and Assembler and various other aspects of the .NET Framework.
  4. Troubleshooting .NET Interoperability by Bob Burns (of Microsoft), Microsoft Developer Network.
    This article looks at correcting common problem with all forms of .NET Interoperability, including correcting common problems in Interop Assemblies. This section is mainly borrowed from information in a similar page in the .NET Framework SDK.
  5. Speech Synthesis & Speech Recognition Using SAPI 5.1 by Brian Long.
    This paper explores how to use the Microsoft Speech API to perform Text-To-Speech and Speech Recognition.
  6. Using The Microsoft Speech API by Brian Long, The Delphi Magazine, Issues 88 (December 2002), 90 (February 2003) and 94 (June 2003).
    These articles went into SAPI 5.x in more depth than the online versions in Reference 5.

About Brian Long

Brian Long used to work at Borland UK, performing a number of duties including Technical Support on all the programming tools. Now he is a trouble-shooter, trainer and technical writer on all things .NET as well as a malware (spyware, adware, rootkits etc.) remover.

Besides authoring a Borland Pascal problem-solving book published in 1994 and contributing towards a Delphi for .NET book in 2004, Brian is a regular columnist in The Delphi Magazine and has had numerous articles published in Developer's Review, Computing, Delphi Developer's Journal and EXE Magazine. He was nominated for the Spirit of Delphi 2000 award and was voted Best Speaker at Borland's BorCon 2002 conference in Anaheim, California by the conference delegates.

There are a growing number of conference papers and articles available on Brian's Web site, so feel free to have a browse.