Click here to Skip to main content
Click here to Skip to main content

Building Rich COMponents with Attributed ATL

, 26 Jan 2004
Rate this:
Please Sign up or sign in to vote.
Tutorial article about how to build components with rich functionality using attributed ATL.

Introduction

There are tons of articles and sample code on how to do something with ATL. Usually they teach how to only add feature to your component and you have to dig a lot of tutorials in order to build something rich-featured.

In this article, I try to cover how to create COM server, expose it to scripting languages, make it an event source, add a VB-style collection to the object and add an ability to your object to report errors.

I didn't set a goal to cover all questions on COM or attributed ATL in this article, so don't expect to find here explanations of every attribute or COM basics. Refer to MSDN for more detailed information. This article is just a quick walk-through on things that will make your COM object more friendly to other programmers.

Get Ready

We will be learning by example. The example is very simple — Windows® Services Manager. The services manager itself will be a COM object we'll write in C++ using attributed ATL, and there will also be a set of scripts in VBScript that will allow us to control services in batch mode.

Start

Coclasses in the Project

When creating a program, we should think about which classes we will have. Here, we'll have the Manager itself, Services collection and Service. We are talking about COM, so they will be our coclasses.

ServicesMgr coclass will provide the user with a set of operations on Services collection and services identified by name. Services collection will provide the user with abilities to iterate services using foreach statement. Service coclass will represent a single service.

Creating a project

To start with ATL project, run Visual Studio .NET IDE and select File/New/Project... command. Select Visual C++ Projects/ATL/ATL Project and enter a name. In this tutorial, I'll use the name "ServicesManager".

Don't change any options in ATL Project Wizard. Let it be Attributed and Dynamic-link library. Click Finish — we're done!

Now we have a dummy COM object. It can be compiled, but does nothing yet.

Open ServicesManager.cpp file. Note [module...] lines there. This is an attribute. It defines the library block. This means that we have DllMain, DllRegisterServer and DllUnregisterServer functions without writing any line of code.

Adding coclasses

Let's add our coclasses to the project.

Right click on ServicesManager project in Solution Explorer and select Add/Add Class. Then select ATL/ATL Simple Object in Add Class - ServicesManager window. Enter ServicesMgr as a name in ATL Simple Object Wizard. Leave all options on the next page as is. Note Dual Interface option is selected. This will help us to provide the functionality of ServicesMgr both to languages like C++ that use VTBL binding of methods and scripting languages that use IDispatch interface to communicate with objects.

Click Finish in wizard's window and get all needed code for our ServicesMgr coclass.

Now, find IServicesMgr interface declaration in ServicesMgr.h file and add the following attributes to this interface: oleautomation, hidden and nonextensible, so it will look like this:

[
    object,
    uuid("2543548B-EFFB-4CB4-B2ED-9D3931A2527D"),
    dual,
    oleautomation,
    nonextensible,
    hidden,
    helpstring("IServicesMgr Interface"),
    pointer_default(unique)
]
__interface IServicesMgr : IDispatch
{
};

Adding these attributes to the interface will make it compatible with OLE automation, hidden in user-oriented object browsers (just to save user's time) and will disallow the user to populate this interface with properties or methods at run-time.

Make another note: we do all stuff right in our C++ code. We don't bother with IDL and other things.

Repeat the steps above to add coclasses smServices (we cannot use Services name, because it is a name of some system namespace) and smService. Add attribute noncreatable to both smServices and smService coclasses. This will prevent the user from creation of these objects.

Adding Functionality

Let's add Start() and Stop() methods to our ServicesMgr coclass. Right click on IServiceMrg node in Class View and select Add/Add Method. Set method name to Start and add a BSTR [in] parameter with name ServiceName. Do the same to add Stop() method. You should get the following code:

__interface IServicesMgr : IDispatch
{
    [id(1), helpstring("method Start")] HRESULT Start([in] BSTR ServiceName);
    [id(2), helpstring("method Stop")] HRESULT Stop([in] BSTR ServiceName);
};

The wizard will also add proper declarations to the coclass and provide you with default implementation of this method. Edit helpstring attributes to give more helpful hint for the user.

Note the id attribute near each method. It sets the dispatch ID of the method. By using this attribute, we don't need to write any dispatching stuff by hand — everything will be done by the compiler.

To simplify testing, "implement" these methods that way:

STDMETHODIMP CServicesMgr::Start(BSTR ServiceName)
{
    Beep(400, 100);

    return S_OK;
}

STDMETHODIMP CServicesMgr::Stop(BSTR ServiceName)
{
    Beep(1000, 100);

    return S_OK;
}

Build the project and run the following script to test it:

Set Mgr = WScript.CreateObject("ServicesManager.ServicesMgr")

Mgr.Start("SomeSvc")

MsgBox "Started!"

Mgr.Stop("SomeSvc")

If you did everything right, then you'll hear a beep, then see the message box and then hear a beep again.

Note, that we testing our object with script, so it's exposed to scripting languages. Note also that we did it with minimum effort by using dual attribute, deriving our interface from IDispatch and using id attribute for the methods.

Starting and stopping services using their names is a good deal, but how's the user expected to know these names? We should provide him with the ability to iterate names of services in order to obtain all available names.

Services Coclass

According to "Building COM Components That Take Full Advantage of Visual Basic and Scripting" article, we should implement an interface with 2 methods and 1 property — _NewEnum() method, property Item and Count() method. These methods have special dispatch ID codes, so the caller will know what to expect from them. Note an underscored _NewEnum() method. This means the method won't be visible for the user.

So, our IsmServices should have these methods and property:

[
    object,
    uuid("5BB63796-959D-412D-B94C-30B3EB8D97F1"),
    dual,
    oleautomation,
    hidden,
    nonextensible,
    helpstring("IsmServices Interface"),
    pointer_default(unique)
]
__interface IsmServices : IDispatch
{
    [propget, id(DISPID_VALUE), 
        helpstring("Returns a service referred by name or index")]
    HRESULT Item([in] VARIANT Index, [out, retval] IsmService** ppVal);

    [id(1), helpstring("Returns number of services")]
    HRESULT Count([out,retval] LONG* plCount);

    [id(DISPID_NEWENUM), helpstring("method _NewEnum")]
    HRESULT _NewEnum([out,retval] IUnknown** ppUnk);
};

Note that property Item and method _NewEnum() use special DISPID identifiers. This is important.

We decided that coclass smServices will perform services enumeration, but on the other hand, coclass ServicesMgr that will provide Services as property, has methods for starting and stopping services. Then it's a good idea to delegate Start() and Stop() methods to smServices. But this will lead us to a bit tricky declaration of smServices coclass.

Now smServices implements IsmServices interface. Remove this declaration and replace it with the following:

class ATL_NO_VTABLE CsmServices
    : public IDispatchImpl<IsmServices>
{
BEGIN_COM_MAP(CsmServices)
    COM_INTERFACE_ENTRY(IDispatch)
    COM_INTERFACE_ENTRY(IsmServices)
END_COM_MAP()
    ...
};

This will provide us with the default implementation of IDispatch interface and expose both IDispatch and IsmServices interfaces to the client.

Now we are able to instantiate smServices coclass ourselves by using this construction (this will implement IUnknown interface for smServices):

CComObject<CsmServices> Services;

Repeat these steps for smService coclass.

Then make a typedef and add a declaration to ServicesMgr coclass:

typedef CComObject<CsmServices> CServices;

class ATL_NO_VTABLE CServicesMgr : 
    public IServicesMgr
{
private:
    CServices *m_pServices;

public:
    CServicesMgr()
    {
        if (SUCCEEDED(CServices::CreateInstance(&m_pServices)))
            m_pServices->AddRef();
    }


    void FinalRelease() 
    {
        if (m_pServices)
            m_pServices->Release();
    }
    ...
};

And finally add Services property to ServicesMgr coclass:

__interface IServicesMgr : IDispatch
{
    [id(1), helpstring("method Start")] HRESULT Start([in] BSTR ServiceName);
    [id(2), helpstring("method Stop")] HRESULT Stop([in] BSTR ServiceName);
    [propget, id(3), helpstring("Collection of available services")]
    HRESULT Services([out, retval] IsmServices** ppVal);
};

Now add EnumServices() method to our smServices coclass (not in interface!):

typedef std::vector<_Service> _Services;

class ATL_NO_VTABLE CsmServices
    : public IDispatchImpl<IsmServices>
{
    ...

private:
    _Services m_Services;

public:

    STDMETHOD(EnumServices)();
    ...
};

STDMETHODIMP CsmServices::EnumServices()
{
    //  Populate m_Services here

    return S_OK;
}

And implement get_Services() method of ServicesMgr:

STDMETHODIMP CServicesMgr::get_Services(IsmServices** ppVal)
{
    if (m_pServices)
    {
        //  Make sure we enumerated services
        HRESULT hr = m_pServices->EnumServices();
        if (SUCCEEDED(hr))
            return m_pServices->QueryInterface(ppVal);
        else
            return hr;
    }

    return E_FAIL;
}

We populated CsmServices coclass with methods without touching the interface that will be used by clients for enumeration. Clients won't need to call EnumServices() directly.

Now add Start() and Stop() methods to smServices coclass using the same way and move their implementation from ServicesMrg coclass.

Enabling Collection Iteration

In order to support collection iteration behavior (For Each ... Next), we should implement _NewEnum() method of CsmServices. The method should return a new object enumerating the collection. This object should implement IEnumVARIANT interface.

Let's create CsmServicesEnum class. This class will copy the list of services from CsmServices and will give the user an ability to iterate it. List of services should be copied because if the user will run two enumerations simultaneously, we'll need to handle them independently.

Add a new ATL Simple Object to the project. Name it smServicesEnum. It doesn't need a custom interface, so remove IsmServicesEnum interface declaration and change the declaration of CsmServicesEnum class and populate it with IEnumVARIANT interface methods:

class ATL_NO_VTABLE CsmServicesEnum
    : public CComObjectRoot
    , IEnumVARIANT
{
BEGIN_COM_MAP(CsmServicesEnum)
    COM_INTERFACE_ENTRY(IEnumVARIANT)
END_COM_MAP()

    ...

public:

    STDMETHOD(Next)(unsigned long celt, 
        VARIANT *rgvar, unsigned long *pceltFetched);
    STDMETHOD(Skip)(unsigned long celt);
    STDMETHOD(Reset)();
    STDMETHOD(Clone)(IEnumVARIANT **ppenum);

};

And don't forget to add typedef to be able to instantiate the object:

typedef CComObject<CsmServicesEnum> CServicesEnum;

Next() method will fetch celt elements of the collection, Skip() will skip a number of items, Reset() method will reset enumeration state to initial, and Clone() method should create a copy of the current state of enumeration.

Our enumerator must hold a copy of services and the current state of enumeration:

class ATL_NO_VTABLE CsmServicesEnum
    : public CComObjectRoot
    , IEnumVARIANT
{
    ...

private:
    _Services m_Services;
    int m_Idx;

public:

    CsmServicesEnum()
        : m_Idx(0)
    {
    }

    void CloneServices(const _Services *pServices)
    {
        m_Services.assign(pServices->begin(), pServices->end());
        m_Idx = 0;
    }

    ...
};

Then _NewEnum() method of smServices will look like this:

STDMETHODIMP CsmServices::_NewEnum(IUnknown** ppUnk)
{
    CServicesEnum *pEnum;
    CServicesEnum::CreateInstance(&pEnum);

    pEnum->AddRef();

    pEnum->CloneServices(&m_Services);
    HRESULT hr = pEnum->QueryInterface(ppUnk);

    pEnum->Release();

    return hr;
}

Now we can implement methods of our enumerator.

STDMETHODIMP CsmServicesEnum::Next(unsigned long celt, 
    VARIANT *rgvar, unsigned long *pceltFetched)
{
    if (pceltFetched)
        *pceltFetched = 0;

    if (!rgvar)
        return E_INVALIDARG;

    for (int i = 0; i < celt; i++)
        VariantInit(&rgvar[i]);

    unsigned long fetched = 0;
    while (m_Idx < m_Services.size() && fetched < celt)
    {
        rgvar[fetched].vt = VT_DISPATCH;

        //  Create and initialize service objects
        CService *pService;
        CService::CreateInstance(&pService);

        pService->AddRef();

        pService->Init(m_Services[m_Idx]);
        HRESULT hr = pService->QueryInterface(&rgvar[fetched].pdispVal);
        
        pService->Release();

        if (FAILED(hr))
            break;

        m_Idx++;
        fetched++;
    }

    if (pceltFetched)
        *pceltFetched = fetched;

    return (celt == fetched) ? S_OK : S_FALSE;
}

STDMETHODIMP CsmServicesEnum::Skip(unsigned long celt)
{
    unsigned long i = 0;
    while (m_Idx < m_Services.size() && i < celt)
    {
        m_Idx++;
        i++;
    }

    return (celt == i) ? S_OK : S_FALSE;
}

STDMETHODIMP CsmServicesEnum::Reset()
{
    m_Idx = 0;

    return S_OK;
}

STDMETHODIMP CsmServicesEnum::Clone(IEnumVARIANT **ppenum)
{
    CServicesEnum *pEnum;
    CServicesEnum::CreateInstance(&pEnum);

    pEnum->AddRef();

    pEnum->CloneServices(&m_Services);
    HRESULT hr = pEnum->QueryInterface(ppenum);

    pEnum->Release();

    return hr;
}

In order to test our enumerator, implement Name and DisplayName properties of smService coclass.

STDMETHODIMP CsmService::get_Name(BSTR* pVal)
{
    *pVal = m_Service.Name.AllocSysString();

    return S_OK;
}

STDMETHODIMP CsmService::get_DisplayName(BSTR* pVal)
{
    *pVal = m_Service.DisplayName.AllocSysString();

    return S_OK;
}

Now we can write a simple test script:

Set Mgr = WScript.CreateObject("ServicesManager.ServicesMgr")

WScript.Echo Mgr.Services.Count

Dim Service
For Each Service In Mgr.Services
  WScript.Echo Service.DisplayName
Next

Only one thing left to complete with collections support. This is the Item property.

STDMETHODIMP CsmServices::get_Item(VARIANT Index, IsmService** ppVal)
{
    _Service svc;
    *ppVal = 0;
    if (VT_BSTR == Index.vt)
    {
        //  Reference by string handle
        CString SvcHandle(Index);
        if (!GetService(SvcHandle, &svc))
            return E_FAIL;
    }
    else
        if (Index.vt & (VT_BYREF | VT_VARIANT))
        {
            //  Reference by VARIANT (Dim i; For i = 0 to x Next; in VBScript)
            LONG i = Index.pvarVal->lVal;
            if (!GetService(i, &svc))
                return E_FAIL;
        }
        else
        {
            //  Reference by integer index
            LONG i = V_I4(&Index);
            if (!GetService(i, &svc))
                return E_FAIL;
        }

    //  Create service
    CService *pService;
    CService::CreateInstance(&pService);

    pService->AddRef();

    pService->Init(svc);
    HRESULT hr = pService->QueryInterface(ppVal);
    
    pService->Release();

    return hr;
}

The code above uses overloaded function GetService(). This function searches for service record using either integer index or service handle. Refer to smServices.cpp for details.

Now we can write the following code to work with our collection:

Set Mgr = WScript.CreateObject("ServicesManager.ServicesMgr")

For i = 0 To Mgr.Services.Count - 1
    WScript.Echo Mgr.Services.Item(i).Name
Next

Congratulations, we added collections support to our COM object. You can use similar technique to add another collection.

Reporting Errors

What if the user specified invalid service handle or index value? What if there're some problems with service manager on our machine? The right solution is to add to our object an ability to report errors.

To report errors, our objects should implement ISupportErrorInfo interface and use SetErrorInfo function to supply information about an error to the caller.

First of all, we'll write an error-reporting function that will handle all deals with SetErrorInfo function for us and will return a special result code.

template<class ErrorSource>
HRESULT ReportError(ErrorSource* pes, ULONG ErrCode, UINT ResourceId = -1)
{
    ICreateErrorInfo *pCrErrInfo;
    IErrorInfo *pErrInfo;
    if (SUCCEEDED(CreateErrorInfo(&pCrErrInfo)))
    {
        //  Set all needed information for Err object in VB or active scripting
        CString Descr;
        if (-1 != ResourceId)
            Descr.LoadString(ResourceId);
        pCrErrInfo->SetDescription(Descr.AllocSysString());
        pCrErrInfo->SetGUID(__uuidof(ErrorSource));
        CString Source = typeid(ErrorSource).name();
        pCrErrInfo->SetSource(Source.AllocSysString());
        if (SUCCEEDED(pCrErrInfo->QueryInterface(IID_IErrorInfo, 
              reinterpret_cast<void**>(&pErrInfo))))
        {
            //  Set error information for current thread
            SetErrorInfo(0, pErrInfo);
            pErrInfo->Release();
        }
        pCrErrInfo->Release();
    }

    //  Report error via result code
    return MAKE_HRESULT(1, FACILITY_ITF, ErrCode);
}

This is a template function. It will use type information to deduct interface GUID of the source and source type name (this could be obtained with Err.Source). It can also load error description from resources.

In order to implement ISupportErrorInfo interface, we'll use support_error_info attribute. Actually this is all we need to do.

[
    ...
    support_error_info("IServicesMgr"),
    ...
]
class ATL_NO_VTABLE CServicesMgr;

//  ...

[
    ...
    support_error_info("IsmService"),
    ...
]
class ATL_NO_VTABLE CsmService;

//  ...

[
    ...
    support_error_info("IsmServices"),
    ...
]
class ATL_NO_VTABLE CsmServices;

Now, let's define error codes and how we'll return them.

For ServicesMgr, erroneous situation is when smServices couldn't be instantiated. Add the following to the code:

class ATL_NO_VTABLE CServicesMgr : 
    public IServicesMgr
{

    ...

private:
    enum
    {
        errNoServices = 0x100
    };
    
    ...

};

STDMETHODIMP CServicesMgr::Start(BSTR ServiceName)
{
    if (m_pServices)
    {
        CString SvcName(ServiceName);
        return m_pServices->Start(SvcName);
    }
    else
        return ReportError(this, errNoServices);
}

STDMETHODIMP CServicesMgr::Stop(BSTR ServiceName)
{
    if (m_pServices)
    {
        CString SvcName(ServiceName);
        return m_pServices->Stop(SvcName);
    }
    else
        return ReportError(this, errNoServices);
}

For smServices, erroneous situation is when services couldn't be enumerated, user specified invalid service handle or index, or service couldn't be stopped or started:

class ATL_NO_VTABLE CsmServices
    : public IDispatchImpl<IsmServices>
{

    ...

private:
    enum
    {
        errCannotEnumServices = 0x200,
        errCannotStart,
        errCannotStop,
        errInvalidIndex,
        errInvalidHandle,
        errCannotOpenServiceManager,
        errCannotEnumerateServices,
        errOutOfMemory,
        errCannotOpenService,
        errCannotQueryStatus,
        errOperationFailed
    };

    ...

};

Then CsmServices::get_Item() will look like this:

STDMETHODIMP CsmServices::get_Item(VARIANT Index, IsmService** ppVal)
{
    _Service svc;
    *ppVal = 0;
    if (VT_BSTR == Index.vt)
    {
        //  Reference by string handle
        CString SvcHandle(Index);
        if (!GetService(SvcHandle, &svc))
            return ReportError(this, errInvalidHandle);
    }
    else
        if (Index.vt & (VT_BYREF | VT_VARIANT))
        {
            //  Reference by VARIANT (Dim i; For i = 0 to x Next; in VBScript)
            LONG i = Index.pvarVal->lVal;
            if (!GetService(i, &svc))
                return ReportError(this, errInvalidIndex);
        }
        else
        {
            //  Reference by integer index
            LONG i = V_I4(&Index);
            if (!GetService(i, &svc))
                return ReportError(this, errInvalidIndex);
        }

    //  Create service
    CService *pService;
    CService::CreateInstance(&pService);

    pService->AddRef();

    pService->Init(svc);
    HRESULT hr = pService->QueryInterface(ppVal);
    
    pService->Release();

    return hr;
}

We can test error reporting with this script:

Set Mgr = WScript.CreateObject("ServicesManager.ServicesMgr")

Err.Clear

On Error Resume Next

WScript.Echo Mgr.Services.Item("qwe").Name  '   "qwe" doesn't exist

MsgBox Err.Source
MsgBox Err.Number
MsgBox Err.Description

Firing Events

The last thing we'll add to our services manager is an ability to notify the client with events. We'll add ServiceOperationProgress() event to notify the client about lengthy starting or stopping of service.

First, we create a brand new event interface:

//  Service operation progress codes
[
    export,
    helpstring("Operation progress codes")
]
enum ServiceProgress
{
    spContinuePending = SERVICE_CONTINUE_PENDING,
    spPausePending = SERVICE_PAUSE_PENDING,
    spPaused = SERVICE_PAUSED,
    spRunning = SERVICE_RUNNING,
    spStartPending = SERVICE_START_PENDING,
    spStopPending = SERVICE_STOP_PENDING,
    spStopped = SERVICE_STOPPED
};

// IServicesMgrEvents
[
    dispinterface,
    nonextensible,
    hidden,
    uuid("A51F19F7-9AF5-4753-9B6F-52FC89D69B18"),
    helpstring("ServicesMgr events")
]
__interface IServicesMgrEvents
{
    [id(1), helpstring("Notifies about lenghtly operation on service")]
    HRESULT ServiceOperationProgress(ServiceProgress ProgressCode);
};

Note that we also added an enumeration that will be visible for users in VB.NET, so they could use special value names instead of numbers.

Now specify IServicesMgrEvents interface as event interface in ServicesMrg coclass using __event __interface keyword. ServicesMrg coclass also must be marked with event_source("com") attribute. To fire ServiceOperationProgress() event, we should use __raise keyword.

[
    ...
    event_source("com"),
    ...
]
class ATL_NO_VTABLE CServicesMgr : 
    public IServicesMgr
{
    ...

    __event __interface IServicesMgrEvents;

    void Fire_ServiceOperationProgress(ServiceProgress Code)
    {
        __raise ServiceOperationProgress(Code);
    }

    ...
};

After doing all this stuff, we can easily notify a client with service status by calling Fire_ServiceOperationProgress() method.

HRESULT CsmServices::WaitPendingService(SC_HANDLE hService, 
                   DWORD dwPendingState, DWORD dwAwaitingState)
{
    //  ...

    while (dwPendingState == ServiceStatus.dwCurrentState)
    {
        //  ...

        if (m_pMgr)
            m_pMgr->Fire_ServiceOperationProgress
            (static_cast<ServiceProgress>(ServiceStatus.dwCurrentState));


        //  ...
    }

    //  ...
}

To test events handling, we'll use the following script:

Set Mgr = WScript.CreateObject("ServicesManager.ServicesMgr", "Mgr_")

Mgr.Start("Alerter")

Sub Mgr_ServiceOperationProgress(ProgressCode)
    WScript.Echo ProgressCode
End Sub

Better run this script using cscript.exe, not wscript.exe, so the output will be done in stdout.

Points of Interest

You can find more about handling events in scripts by reading "Scripting Events" article in MSDN (Andrew Clinick, 2001). This was really an interesting thing for me.

There's also a great article "Building COM Components That Take Full Advantage of Visual Basic and Scripting" (by Ivo Salmre, 1998, MSDN). In this article, you'll find basic information about the features your COM server needs to be seamlessly used in C++, VB and VBScript languages.

If you want to debug similar objects, then just write a script in VBScript, set cscript.exe as debugging command and path to the script as command arguments. Then place breakpoints where needed and run the project. This is the easiest way to debug such COM objects.

History

Version 1.0 so far.

License

This article has no explicit license attached to it but may contain usage terms in the article text or the download files themselves. If in doubt please contact the author via the discussion board below.

A list of licenses authors might use can be found here

Share

About the Author

Alex Kolesnichenko
Software Developer (Senior) w2bi, Inc
United States United States
Started professional career in software development back in 2000, in Ukraine. Founder and owner of a boutique software company called ByteGems.com Software. Currently employed full time at w2bi, Inc in New Jersey USA.
 
My buzzwords at the moment: .NET, C#, C++, Win32, ATL, MFC, SQL, ASP.NET, WinForms, WebForms, MVC, EF, LINQ, Sockets, TCP/IP, Remoting.

Comments and Discussions

 
GeneralFInalRelease never called Pinmemberkrfvgew010-Dec-09 10:13 
GeneralRe: FInalRelease never called PinmemberVitaly Tomilov3-May-10 7:16 
GeneralEvents in Internet Explorer Pinmemberkrssagar16-Aug-05 12:35 
GeneralFire events PinsussAnonymous12-Jun-05 19:20 
GeneralRe: Fire events Pinmembermcreej23-Aug-05 4:36 
General[out,retval]NULL crashes in VBScript Pinmemberkrssagar17-May-05 5:19 
GeneralNice article Pinmemberaleksey->25-Apr-05 10:20 
GeneralRe: Nice article Pinmemberkrssagar22-Jul-05 5:39 
General.NET Pinmembereranation19-Apr-04 14:11 
GeneralRe: .NET Pinmembertwask20-Apr-04 2:25 
GeneralThanks PinmemberJaroslav Klumpler23-Feb-04 7:47 
GeneralRe: Thanks Pinmembertwask23-Feb-04 8:08 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.

| Advertise | Privacy | Mobile
Web01 | 2.8.140814.1 | Last Updated 27 Jan 2004
Article Copyright 2004 by Alex Kolesnichenko
Everything else Copyright © CodeProject, 1999-2014
Terms of Service
Layout: fixed | fluid