Click here to Skip to main content
12,627,681 members (33,260 online)
Click here to Skip to main content
Add your own
alternative version

Stats

60.4K views
21 bookmarked
Posted

Microsoft Visual C++ .NET 2003 Kick Start Chapter 3: The .NET Base Class Libraries

, 17 Feb 2004
Rate this:
Please Sign up or sign in to vote.
An introduction to the .NET base class libraries.
Sample Image - 0672326000c.jpg
Author Kate Gregory
Title Microsoft Visual C++ .NET 2003 Kick Start
Publisher Sams
Published DEC 04, 2003
ISBN 0672326000
Price US$ 34.99
Pages 336

The .NET Base Class Libraries

In This Chapter

  • Libraries Shared Across Languages
  • Namespaces in C++
  • The System Namespace
  • Other Useful Namespaces
  • In Brief

Libraries Shared Across Languages

When you write managed C++, you have access to all of the managed code libraries that come with the .NET Framework: the Base Class Libraries, ADO.NET, ASP.NET, and so on. These libraries are modern and powerful. They provide services, such as XML processing, that weren't even thought of when older libraries such as MFC and ATL were first written.

In sharp contrast to the historical capabilities of C++ and Visual Basic, on the .NET Framework these two languages share the same class libraries. Every library class and method that's available from Visual Basic is available from managed C++ and from C#. Every library class and method that's available from C# is available from Visual Basic and Managed C++.

The advantages of a shared class library are many. They include:

  • Reduced learning time when moving from one .NET-supported language to another
  • A larger body of samples and documentation, because these do not need to be language specific
  • Better communication between programmers who work in different .NET-supported languages

The C++ Advantage - C++ can use the same class libraries as C# and VB.NET. Does it work the other way around? No. There are libraries of unmanaged code available from managed C++ that cannot be called from Visual Basic or C#—ATL and MFC are just two examples. However, it's unlikely that a Visual Basic or C# programmer would want to use those libraries, because their functionality is provided elsewhere; the capability to call them from managed C++ helps simplify a port from unmanaged to managed C++.



Working in Multiple Languages - I have a small consulting firm, and often our clients specify the programming language we are to use for a project. When several projects are on the go at once, I might switch languages several times in the course of a single day, as I help my associates with problems or track down the last few bugs in systems that are almost finished.

Before I started using the .NET Framework, about once a month I'd suffer a "brain freeze" and for a moment or two forget how to perform some really simple task, like determining whether a string contains a particular character, in the language of the moment. Usually at those times, my fingers would start typing the method or operator in randomly chosen other languages or libraries: Perl, Visual Basic, MFC, STL, Java—anything except the one I wanted. Now, with a common set of libraries across languages, I don't have to "context switch" nearly as often—and I avoid those "deer in the headlights" moments.


In the past, just because you knew how to perform a specific task, such as writing some text to a file in Visual C++, didn't mean you knew how to do that same task in Visual Basic. A great tutorial you found on database access in Visual Basic wasn't much help if you wanted to write your database application in Visual C++. Now, as long as you're working in managed C++, the walkthroughs, tutorials, and samples you find for any managed language—and you'll find plenty for Visual Basic and C#—are equally applicable to Visual C++. You'll have to translate the actual language elements, of course, but an explanation of a particular class or a method of that class is valid no matter which .NET supported language you intend to use.

Namespaces in C++

Namespaces are a C++ feature designed to eliminate name conflicts, such as having two classes, each in different libraries, called String. Before namespaces were added to the language, library developers tried to make their names unique by adding letters to them: One developer's string class might be called GCString, whereas another developer might call it TKString, the string class in MFC is called CString, and so on. This approach is ugly and reduces, but doesn't prevent, name conflicts.

With namespaces, classes can have simple names. Name conflicts are much less likely, because in addition to a short or local name, classes have a fully qualified name that includes their namespace. Here's a slightly artificial example (normally namespaces are used in separate libraries, not jumbled together in one piece of code like this) that illustrates how they work:

namespace One
{
  class Common 
  {
  private:
    int x;
  public:
    Common(int a): x(a) {}
    int getx() {return x;}
  };
  void Do()
  {
    Common c(3);
    Console::WriteLine(__box(c.getx()));
  }
}
namespace Two
{
  class Common
  {
  private:
    double d1, d2;
  public:
    Common(double param1) : d1(param1),d2(param1) {}
    double getd1() {return d1;}
    double getd2() {return d2;}
  };
  void Do()
  {
    Common c(3);
    String* output = String::Concat(__box(c.getd1()), S" " ,
      __box(c.getd2()));
    Console::WriteLine(output);
  }
}

int _tmain()
{
  //Common c(3); // ambiguous
  One::Common c1(3);
  Two::Common c2(3);
  //Do(); //ambiguous
  One::Do();
  Two::Do();
  return 0;
}

This code defines two namespaces, named One and Two. In each namespace, there is a class called Common and a function called Do(). Inside the namespace, there's no problem referring to Common just using its short or local name. The two Do() functions accomplish this without error; each is working with the Common class from its own namespace.

The main function, _tmain(), cannot refer to Common or to Do() using a short name. (The two lines of code commented out in _tmain() cause compiler errors.) It has to use the fully qualified name: the namespace name and the class name, separated by the scope-resolution operator (::).

A using statement allows you to refer to a class with only its short name. It does not cause the compiler or linker to include any files that otherwise wouldn't have been included in your build; it's just a convenience to reduce typing. The example main function can be rewritten as:

using namespace One;
int _tmain()
{
  Common c1(3);
  Two::Common c2(3);
  Do();
  Two::Do();
  return 0;
}

Modern class libraries are each in their own namespace—for example, the templates in the Standard Template Library are in the namespace std. The developers of the .NET Framework built on this concept, dividing the class libraries into namespaces and sub-namespaces. This makes them easier to learn and document.

To use a class in a namespace, you have two choices:

  • Call the class by its full name (such as System::Math) whenever you're using it:
    x = System::Math::PI / 4;
    System::String* s = new System::String("hello");
  • Add a using statement at the top of the file, and then call the class by its name within the namespace:
    using namespace System;
    ...
    x = Math::PI / 4;
    String* s = new String("hello");

Punctuation: Using . or :: - In most other .NET languages, the punctuation between the namespace name and the class name is a dot (.). For example, in both Visual Basic and C#, a developer would type System.Math.PI. But in C++, you use a double colon (::), called the scope-resolution operator. In the documentation, if you see a reference to System.Something, you just need to change it to System::Something in your code. Use the scope-resolution operator between namespace and sub-namespace, namespace and class, or sub-namespace and class.

As always, you use the dot between the name of an object and an ordinary member function, and the scope-resolution operator between the name of the class and a static member function or variable. In the previous examples, PI is a static member variable of the Math class. In other .NET languages, the punctuation between class or object name and function is always a dot, even when the function is static. This can make the documentation confusing. Most occurrences of . in the documentation should be changed to ::.

IntelliSense, the feature that pops up lists for you to choose from as you type, really helps with this confusion. If you type "System." into a file of C++ code, no list appears and the status bar reads:

IntelliSense: 'Could not resolve type for 
expression to the left of . or ->'

On the other hand, if you type "System::", a list of namespaces and classes appears for you to choose from. Use the lack of feedback from IntelliSense as an indicator that you have typed the wrong thing, and you'll find working from the documentation a lot less confusing.


The second choice is a better approach when you're going to be typing class names from the namespace a number of times, because it saves you typing the namespace name repeatedly. I prefer the first choice when I'm only typing a class name once, because the fuller name gives more clues to a maintainer about what the code is doing. This is even more important when you're using classes from more obscure namespaces—everyone uses classes from System and is familiar with many of them, but the System::Web::Security namespace, for example, might not be so obvious to you or to those who will maintain your code.

Whether you choose to add a using statement to your file or not, you must add a #using directive to the top of your source file. When you create a new .NET project, one of these directives is added for you automatically:

#using <mscorlib.dll>

This gives you access to all the classes that are directly under the System namespace, such as the System::Math class used in these examples. The documentation for the classes that are in sub-namespaces of System includes a line like this one, from System::Xml.Document:

Assembly: System.XML.dll

This is a clue that you need to add this line to your file:

#using <System.XML.dll>

Don't worry about what seem to be extra dots in the filename, and don't change dots to :: here. If you will be using a particular assembly in every file within a project, you can add a reference to the assembly instead (right-click References in Solution View and choose Add Reference).

The System Namespace

You'll use the classes in the System namespace in almost every .NET application. All the data types are represented, for example. Two classes in particular deserve special mention: System::Console and System::String.

The System::Console Class

System::Console, called System.Console in the documentation, represents the screen and keyboard in a simple console application. After you add a using statement to your file so you don't have to type System:: every time, the Console class is simple to use. To write a line of text to the screen, you use the static function WriteLine():

Console::WriteLine("Calculations in Progress");

If you want to write text without a following line break, use the Write() function instead.

To read a line of text from the keyboard, first you should write out a line to prompt the users, and then read the entire line into a System::String object with the static ReadLine() function:

Console::WriteLine("Enter a sentence:");
String* sentence = Console::ReadLine();

If you want to read in something more complicated than a single string, there really isn't any support for it within the Console class, nor the classes in the System::IO namespace covered later in this chapter. You can read it into a string and then use string member functions to separate it into the pieces you want.

If you want to write formatted output, there's an overload of WriteLine that's reminiscent of printf(), except that you don't have to tell the function the type of each parameter. For example:

Console::WriteLine("The time is {0} at this moment", 
          System::DateTime::Now.ToShortTimeString() );

You can write out a number of parameters at once. Use the placeholders, the things in brace brackets in the format string, to call for the parameter you want. As you can see, the count is zero-based. Here's an example:

Console::WriteLine("{0} lives at {1}", name, address);

The System::String and System::Stringbuilder Classes

The String class represents a string, such as "Hello" or "Kate Gregory". That's familiar ground for any programmer. But working with .NET strings can be quite strange for an experienced C++ or MFC programmer. They are certainly very far removed from the arrays of characters that you might be used to working with.

If you've ever worked with the MFC class CString, you've probably written code like this:

CString message = "Value of x, ";
message += x;
message += "is over limit.";

You might guess that the .NET equivalent would be:

String* message = "Value of x, ";
message += x;
message += "is over limit.";

This just gets you a lot of strange compiler errors about illegal pointer arithmetic. The message variable is a pointer to a String instance, so you can't use the + operator. You can't do it in a single line either, like this:

String* message = "Value of x, " + x + "is over limit.";

The bottom line is that you can't treat .NET strings like C++ or C strings. So how do you build a string from several substrings, or from several pieces in general? If you want to build it so you can write it out, forget building the string, and use formatted output as described in the previous section. Or use the Format() method, which is reminiscent of sprintf(), and of the Format() method of the old MFC class CString. But if you need to build a string in little bits and pieces, your best choice is a companion class called StringBuilder, from the System::Text namespace. You use a string builder like this:

String* name = "Kate";
System::Text::StringBuilder* sb = new System::Text::StringBuilder("Hello ");
sb->Append(name);

Using a string builder is more efficient than modifying a string as you go, because .NET strings actually can't be modified; instead, a whole new one is created with your changes, and the old one is cleaned up later. StringBuilder has all sorts of useful methods like Append(), Insert(), Remove(), and Replace() that you can use to work on your string. When it's ready, just pass the string builder object to anything that's expecting a string:

Console::WriteLine(sb);

The framework gets the built string from the string builder and passes it to the function for you.

The String class has its own useful methods too. Consider the problem mentioned earlier—reading something other than a single string from the keyboard. The easiest way for you to tackle the problem is to write code that reads the line of input into a string, and then works with it. Here's a simple example:

Console::WriteLine("Enter three integers:");
String* input = Console::ReadLine();
String* numbers[] = input->Split(0);
int a1 = Convert::ToInt32(numbers[0]);
int a2 = Convert::ToInt32(numbers[1]);
int a3 = Convert::ToInt32(numbers[2]);

This code uses the Split() member function of the String class. It splits a String into an array of strings based on a separator character. If you pass in a null pointer (0), as in this example, it splits the string based on whitespace such as spaces or tabs, which is perfect for this situation. The ToInt32() method of the Convert class converts a String to an integer.

If you already know how to manipulate strings, you might appreciate a quick "cheat sheet" for the String class. Table 3.1 is just such a summary.

Table 3.1 String Functions

C Runtime MFC CString System::String
strcpy operator= operator=
strcat operator+= Append
strchr Find IndexOf
strcmp operator == or Compare Compare
strlen GetLength() Length
strtok n/a Split
[] [] or GetAt() Chars
sprintf Format Format
n/a Left or Right or Mid Substring

If you've worked with strings in other languages, you'll appreciate System::String functions such as PadLeft(), PadRight(), Remove(), and StartsWith(). If those names aren't familiar to you, check the Visual C++ documentation. You might be able to do what you want with a single function call!

The System::DateTime Structure

The DateTime structure represents a date or a time, or both. It has a number of useful constructors to create instances using a numeric date and time, or a number of ticks (useful when you're working with older C++ code). Here are some examples:

DateTime defaultdate;
Console::WriteLine(defaultdate.ToLongDateString());
DateTime Sept28(2003,9,28,14,30,0,0);
Console::Write(Sept28.ToShortDateString());
Console::Write(S" ");
Console::WriteLine(Sept28.ToShortTimeString());
DateTime now = DateTime::Now;
Console::WriteLine(now.ToString());

It can be intimidating to remember the parameters to the seven-integer constructor, but it's simple when you realize they go from largest to smallest: year, month, day, hour, minute, second, and millisecond. The millisecond parameter is optional and if you want, you can omit all the time parameters completely.

On September 17, 2003, this code produces the following output:

Monday, January 01, 0001
9/28/2003 2:30 PM
9/17/2003 1:17:41 PM

It matters that DateTime is a structure, not a class, because it is managed data. Managed classes can only be allocated on the heap with new; managed structures can only be allocated on the stack as in these examples.

To get the individual parts of a date, use these properties:

  • Day: The day of the month
  • Month: 1 to 12
  • Year: Always four digits
  • Hour: 0 to 23
  • Minute
  • Second
  • DayOfWeek: 0 means Sunday
  • Format: Creates a string based on the time and date, using a format string

The format string passed to Format() is either a single character representing one of a number of "canned" formats, or a custom format string. The most useful canned formats include:

  • d: A short date, such as 12/19/00
  • D: A long date, such as Tuesday, December 19, 2000
  • f: A full time and date, such as Tuesday, December 19, 2000 17:49
  • g: A general time and date, such as 12/19/00 17:49
  • s: A sortable time and date, such as 2000-12-19 17:49:03
  • t: A short time, such as 17:49
  • T: A long time, such as 17:49:03

If none of the canned formats has what you need, you can make your own by passing in strings such as "MMMM d, yy" for "December 3, 02" or whatever else you desire. You can find all the format strings in the help installed with Visual Studio.

Other Useful Namespaces

There are plenty of other useful classes contained in sub-namespaces of the System namespace. Some are covered elsewhere in this book. Four are covered here because almost every .NET developer is likely to use them: System::IO, System::Text, System::Collections, and System::Threading.

The System::IO Namespace

Getting information from users and providing it to them is the sort of task that can be incredibly simple (like reading a string and echoing it back to the users) or far more complex. The most basic operations are in the Console class in the System namespace. More complicated tasks are in the System::IO namespace. This namespace includes 27 classes, as well as some structures and other related utilities. They handle tasks such as:

  • Reading and writing to a file
  • Binary reads and writes (bytes or blocks of bytes)
  • Creating, deleting, renaming, or moving files
  • Working with directories

This snippet uses the FileInfo class to determine whether a file exists, and then deletes it if it does:

System::IO::FileInfo* fi = new System::IO::FileInfo("c:\\test.txt");
if (fi->Exists)
  fi->Delete();

This snippet writes a string to a file:

System::IO::StreamWriter* streamW = 
        new System::IO::StreamWriter("c:\\test.txt");
streamW->Write("Hi there" );
streamW->Close();

Be sure to close all files, readers, and writers when you have finished with them. The garbage collector might not finalize the streamW instance for a long time, and the file stays open until you explicitly close it or until the instance that opened it is finalized.


When Typing Strings with Backslashes - The backslash character (\) in the filename must be "escaped" by placing another backslash before it. Otherwise the combination \t will be read as a tab character. This is standard C++ behavior when typing strings with backslashes.


Check the documentation to learn more about IO classes that you can use in console applications, Windows applications, and class libraries. Keep in mind also that many classes can persist themselves to and from a file, or to and from a stream of XML.

The System::Text Namespace

Just as Console offers simple input and output abilities, the simplest string work can be tackled with just the String class from the System namespace. More complicated work involves the System::Text namespace. You've already seen System::Text::StringBuilder. Other classes in this namespace handle conversions between different types of text, such as Unicode and ASCII.

The System::Text::RegularExpressions namespace lets you use regular expressions in string manipulations and elsewhere. Here is a function that determines whether a string passed to it is a valid US ZIP code:

using namespace System;
using namespace System::Text::RegularExpressions;
// . . .
String* Check(String* code)
{
  String* error = S"OK";
  Match* m;
  switch (code->get_Length())
  {
  case 5:
    Regex* fivenums;
    fivenums = new Regex("\\d\\d\\d\\d\\d");
    m = fivenums->Match(code);
    if (!m->Success) 
      error = S"Non numeric characters in 5 digit code";
    break;
  case 10:
    Regex* fivedashfour;
    fivedashfour = new Regex("\\d\\d\\d\\d\\d-\\d\\d\\d\\d");
    m = fivedashfour->Match(code);
    if (!m->Success) 
      error =  S"Not a valid zip+4 code";
    break;
  default:
    error = S"invalid length";
  }
  return error;
}

The Regex class represents a pattern, such as "five numbers" or "three letters." The Match class represents a possible match between a particular string and a particular pattern. This code checks the string against two patterns representing the two sets of rules for ZIP codes.

The syntax for regular expressions in the .NET class libraries will be familiar to developers who have used regular expression as MFC programmers, or even as UNIX users. In addition to using regular expressions with classes from the System::Text namespace, you can use them in the Find and Replace dialog boxes of the Visual Studio editor, and with ASP.NET validation controls. It's worth learning how they work.

Regular Expression Syntax

A regular expression is some text combined with special characters that represent things that can't be typed, such as "the end of a string" or "any number" or "three capital letters."

When regular expressions are being used, some characters give up their usual meaning and instead stand in for one or more other characters. Regular expressions in Visual C++ are built from ordinary characters mixed in with these special entries, shown in Table 3.2.

Here are some examples of regular expressions:

  • ^test$ matches only test alone in a string.
  • doc[1234] matches doc1, doc2, doc3, or doc4 but not doc5.
  • doc[1-4] matches the same strings as doc[1234] but requires less typing.
  • doc[^56] matches doca, doc1, and anything else that starts with doc, except doc5 and doc6.n -H\~ello matches Hillo and Hxllo (and lots more) but not Hello. H[^e]llo has the same effect.
  • [xy]z matches xz and yz.
  • New *York matches New York, NewYork, and New York (with several spaces between the words).
  • New +York matches New York and New York, but not NewYork.
  • New.*k matches Newk, Newark, and New York, plus lots more.
  • World$ matches World at the end of a string, but World\$ matches only World$ anywhere in a string.

Table 3.2 Regular Expression Entries

Entry Matches
^ Start of the string.
$ End of the string.
. Any single character.
[] Any one of the characters within the brackets (use – for a range, ^ for "except").
\~ Anything except the character that follows.
* Zero or more of the next character.
+ One or more of the next character.
\w A single letter or number, or an underscore. (These are called word characters and are the only characters that can be used in a variable name.)
\s Whitespace (tabs or spaces).
\d A single numerical digit.
\ Removes the special meaning from the character that follows.

The System::Collections Namespace

Another incredibly common programming task is holding on to a collection of objects. If you have just a few, you can use an array to read three integers in one line of input. In fact, arrays in .NET are actually objects, instances of the System::Array class, which have some useful member functions of their own, such as Copy(). There are times when you want specific types of collections, though, and the System::Collections namespace has plenty of them. The provided collections include:

  • Stack. A collection that stores objects in order. The object stored most recently is the first taken out.
  • Queue. A collection that stores objects in order. The first stored is the first taken out.
  • Hashtable. A collection that can be searched far more quickly than other types of collections, but takes up more space.
  • ArrayList. An array that grows as elements are added to it.
  • SortedList. A collection of two-part (key and value) items that can be accessed by key or in numerical order.
  • BitArray. A compact way to store an array of true/false flags.

One rather striking omission here is a linked list. You have to code your own if you need a linked or double-linked list.

The System::Threading Namespace

Threading has been a difficult part of Windows programming from the very beginning. It's quite a bit simpler in .NET. The vital classes for threading are in the System::Threading namespace. These include classes such as Mutex, Thread, and ThreadPool, which developers with experience in threaded applications will recognize instantly.

A thread is a path of execution through a program. In a multithreaded program, each thread has its own stack and operates independently of other threads running within the same program.


How Many Threads? - Any application always has at least one thread, which is the program's primary or main thread. You can start and stop as many additional threads as you need, but the main thread keeps running as long as the application is active.


A thread is the smallest unit of execution, much smaller than a process. Generally, each running application on your system is a process. If you start the same application twice (for example, Notepad), there are two processes: one for each instance. It is possible for several instances of an application to share a single process: For example, if you choose File, New Window in Internet Explorer, two applications appear on your taskbar, and they share a process. The unfortunate consequence is that if one instance crashes, they all do.

Writing a multithreaded application is simple with classes from the System::Thread namespace. First, you need to think of some work for a new thread to do: some slow calculation that you want to run without slowing the responsiveness of your application. This work will go into a function, and the function will be executed on a new thread.


The Thread Proc - For a long time, Windows C++ programmers have called the function the thread proc (short for procedure).


Your thread proc must be a member function of a garbage-collected class. For example:

public __gc class Counter
{
public:
  void Countdown()
  {
   String* threadName = Thread::CurrentThread->Name;
   Console::Write(S"This is the current thread: ");
   Console::WriteLine(threadName);
   for (int counter = maxCount; counter >= 1; counter--)
   {
     Console::WriteLine(S"{0} is currently on {1}.",threadName,
               __box(counter));
   }
   Console::WriteLine(S"{0} has finished counting down from {1}.",
             threadName, 
      __box(maxCount));
  }
};

The heart of this function is the for loop that counts down from maxCount to zero. It uses the Name property of the thread that is executing the function, because the sample that uses this class calls the function on two different threads. Normally, the function that does the asynchronous work would not communicate with the user; it would be quietly working in the background, doing some long slow work. It might, however, update a progress bar, or an icon that indicates a background task is in progress.

This code calls the thread proc on a new thread and also on the application's main thread:

Console::Write("Enter a number to count down from: ");
maxCount = Int16::Parse(Console::ReadLine());
Thread::CurrentThread->Name = "Main Thread";

Counter* counter = new Counter();
ThreadStart* SecondStart = new ThreadStart(counter,Counter::Countdown);
Thread* secondThread = new Thread(SecondStart);
secondThread->Name = "Secondary Thread";
secondThread->Start();

counter->Countdown();

This code keeps the number entered by the user in a global variable called maxCount. Because ReadLine returns a pointer to a System::String instance, the static Parse() method of Int16 is used to convert a string to an integer so that it can be stored in maxCount.

To execute a particular function on a new thread, you create a Thread object and call its Start() method. To create a Thread object, you need a ThreadStart object, and the constructor for ThreadStart needs a reference to an instance of a garbage-collected class (counter in this example), and a function pointer (just the name of the function without the parentheses) to a member function of that garbage-collected class.

Because this code calls Countdown on a new thread and also on the main thread, the output shows the two threads taking turns, as in Figure 3.1.

Figure 3.1

A multithreaded application demonstrates how threads take turns doing their work.

In Brief

  • The libraries that come with the .NET Framework are large, and cover almost every task programmers are likely to tackle. Using them can save programmers hours or days of work.
  • The same class libraries are used in Visual Basic, C#, and managed C++, but managed C++ can also call some unmanaged libraries that Visual Basic and C# cannot access.
  • The System namespace holds classes for simple common tasks, and a large number of sub-namespaces for slightly less common (but still important) tasks, including string manipulation, IO, and threading.

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

Sams Publishing
United States United States
No Biography provided
Group type: Organisation

1 members


You may also be interested in...

Pro
Pro

Comments and Discussions

 
Questionwrong section? Pin
.dan.g.18-Feb-04 19:15
member.dan.g.18-Feb-04 19:15 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    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 | Terms of Use | Mobile
Web02 | 2.8.161205.3 | Last Updated 18 Feb 2004
Article Copyright 2004 by Sams Publishing
Everything else Copyright © CodeProject, 1999-2016
Layout: fixed | fluid