Click here to Skip to main content
13,635,085 members
Click here to Skip to main content
Add your own
alternative version

Tagged as

Stats

7.6K views
6 bookmarked
Posted 14 Dec 2017
Licenced CPOL

Implementing a C++ Const Object That Can be Given a Value Post Declaration

, 8 Jan 2018
Rate this:
Please Sign up or sign in to vote.
C++ only allows you to give a value to a const qualified object at the moment of declaration. There are rare instances where you want to define the value shortly after the moment of declaration.

Introduction

In C++, an object can be qualified with const to indicate that this is an immutable value. Per the specification, the object so qualified cannot be modified. The benefit is that the compiler will perform checks to ensure the object does not receive a new assignment. This means that a developer can prevent accidental assignments (i.e. if (a = b) instead of the intended (if a == b)) as well as faulty logical operations within the code that might result in a new assignment. The specification puts one burden on using this qualifier and gaining this particular wonderful benefit of the compile time check: the object must be initialized at the point of declaration.

The initializer can be a compile-time known value or can be a value known at run-time (by calling a function that returns a value). Once initialized, that value cannot be changed. The compiler will enforce this rule and produce an error on any attempt in code to modify the variable value after initialization, which again is at the point of declaration.

Examples from the specification showcase the principle:

const int ci = 3;                    // cv-qualified (initialized as required)
ci = 4;                              // ill-formed: attempt to modify const

int i = 2;                           // not cv-qualified
const int* cip;                      // pointer to const int
cip = &i;                            // OK: cv-qualified access path to unqualified
*cip = 4;                            // ill-formed: attempt to modify through ptr to const

int* ip;
ip = const_cast<int*>(cip);    // cast needed to convert const int* to int*
*ip = 4;                             // defined: *ip points to i, a non-const object

const int* ciq = new const int (3);  // initialized as required
int* iq = const_cast<int*>(ciq); // cast required
*iq = 4;                             // undefined: modifies a const object

In almost every situation, this is perfectly fine. There are some very limited exceptions, however, where the value of the object that should be kept from changing is only known shortly after the moment of instantation.

I encountered this problem while writing a Windows application. In this particular circumstance, I was using the hInstance variable that is provided as a value passed into the WinMain entry point. The value is used in different Windows APIs and should never change. My challenge was that I wanted to use this value in a function called by the Windows messaging interface. The messaging API is called from another thread and only has access to "global" type variables, which hInstance is not. No problem, I just declare a global variable and pass the hInstance variable to it. But I wanted to protect myself and any future maintainer of the code from changing that value.

Normally applying const as a qualifier to the variable would solve the problem, but the global variable has to be declared and instantiated before I could ever know the value of hInstance. C++ would appear to prevent this instance from being a const qualified variable.

Another situation followed these lines. Windows provides Direct3D 11 and Direct3D 12. Direct3D 12 is available only on Windows 10 and is not intended for general everyday graphics use. Direct3D 11 is available from Windows XP forward (with some exceptions) and is intended for general use and is still heavily programmed for. In writing a graphics oriented library for use on Windows, the design wanted to allow to declare the use of Direct3D 11 or 12. However, it also would allow that if Direct3D 12 was not available, Direct3D 11 could be used as a fall-back.

In this circumstance, it was felt that using a variable (it could be a bitfield or an enum or similar) declared external in the library and declared in the developer's code would suffice for the design. This variable was never to be used by the developer, it was only consumed and used inside the library. This variable should by all accounts be a constant object.

In design review, it was determined that for efficiency, you could just reference this constant value. The hope was to have a variable like an enum that had values "D3D11, D3D12, Either". If it said "use D3D12", you used the Direct3D 12 parts of the library and if it said "use D3D11", you used Direct 3D 11. It was in the instance where it said "try D3D 12 and if it's not available use D3D 11". This only occurred if you were not running Windows 10 or for some reason, the D3D12.dll was not available. You could only know this after the application was running and you could test for the OS or the DLL. The desire was to modify the variable after the OS check and change "Either" to the appropriate "D3D12" or "D3D11" variable. Of course, if the variable was constant qualified, it cannot be changed.

This meant that the qualifier needed to be removed, which eliminated the very nice compile time checks. The variable would never change except at the start of the program after the OS check ran.

So what was wanted was essentially a 1-time change to a constant object to accommodate the fact that I really want to initialize the object later than the specification allows. A change that should only occur at the beginning of the application and would only be exposed or consumed internally by the function or library. Otherwise, I want to use the constant qualifier in all other ways as intended.

Now, you are free to disagree with the design decision but I present them as an illustrative case. There are the rare occasions when a developer or architect will want to have an object that is unchangeable except in a very, very narrow use case. This may be to a change in “environment” after the application has started and the immutable object has been initialized, or an immutable global type variable is needed but it can only be initialized after the point of declaration when the application starts, or it could also be that the immutableness only applies to a particular single instance run-through (that is everything resets after the run-through).

The driving factor is that as a developer, I want to take advantage of the compile time checks that ensure the object does not receive an assignment after it's initialized. The problem is when I want to initialize appears to be prohibited by the specification.

I could solve this problem by creating a class and introducing checks and assignments to basically replicate my constant qualifier. The trade-off then is added complexity and run-time checks. This may be sufficient or desirable depending on the situation of the developer and the design principles.

What if my design would rather not take that approach. Is there a way to keep the constant qualifier and keep the compile time checks? And would this solution be compliant with the C++ specifications and not introduce some "clever" trick that would come back to haunt me?

Background

The answer is yes and yes! This solution attempts to maintain every aspect of a constant qualifier only shifting the moment of initialization to a point that should be shortly past the point of declaration (but not after it's first use). The solution may not delight everyone and it's not recommended as a trick to get around const qualified objects. It's quite possible to use this and abuse const but that is not the recommendation being provided.

On initial examination, it's easy to be tempted by const_cast. It supposedly casts away the constant qualifier so one might be tempted to do something like this:

const int x = 42;
const_cast<int>(x) = 46;

Using const_cast is intended to be used for older code or C code where a const is not declared as in the function declaration that follows:

struct X {
    mutable int i;
    int j;
};

struct Y {
    X x;
    Y();
};

const Y y;
y.x.i++;                   // well-formed: mutable member can be modified
y.x.j++;                   // ill-formed: const-qualified member modified
Y* p = const_cast<Y*>(&y); // cast away const-ness of y
p->x.i = 99;               // well-formed: mutable member can be modified
p->x.j = 99;               // undefined: modifies a const memberint i = 2;

Or:

oldcode.cpp
-------------
int *ip;
void do_foo(int *a)
{
  ip = a;
}

newcode.cpp
-------------
int i =2
const int j = 2;
const int *cip = &i;
const int *cjp = &j;
do_foo(const_cast<int*>(cip)); //ok, cip points to a non-const object i
do_foo(cip);                   //bad, no guarantee of immutability on do_foo's a
do_foo(const_cast<int*>(cij)); //undefined, removed constant qualifer but updating const j

const_cast while removing the constant qualifier does not make the underlining object non-constant, even for an instance.

The specification says something about that. In the ISO/IEC 14882:2014 specification 7.16.1 subsection 4 states:

Quote:

Except that any class member declared mutable (7.1.1) can be modified, any attempt to modify a const
object during its lifetime (3.8) results in undefined behavior.

The key is that the modification results in undefined behaviour. This means that the specification gives no guidance and it's left to the compiler implementation to decide what to do in this situation. The compiler may or may not create actual assembly that overrides the value. There’s no guarantee. In fact, with the MSVC compiler, the variable x is actually placed into a read-only memory and attempts to change its result in an exception. And even if it did, the compiler isn't accountable to keep it that way and this behaviour could change from compiler release to release.

The specification is clear -"any attempt to modify a const object during its lifetime results in undefined behavior." Within the C++ language, there isn't a mechanism that allows for a change to the constant qualified object. Still, the specification doesn't say that any of this results in "illegal" behaviour, just undefined, and that's our opportunity. We need to define that behaviour.

In truth, C++, and this applies to all non-interpreted languages (and even then, it could be argued this applies in the end to interpreted languages, are instructions to a compiler on the type of Assembly code to auto-generate. To define our implementation, we need to guarantee the Assembly generated by the C++ compiler, which means we can't deviate from the specification, or write our own Assembly and ensure the linker uses our code. In essence, we are going to take over the job of the compiler for this very specific instance. This may seem dangerous or complicated but after examining the C++ underpinings and how all of this comes together, you will see it's very straightforward and simple.

To figure out the "definition", we need to examine the code and what is being generated and then work from there looking to understand how we can add our implementation while maintaining adherence to specifications and general coding practices.

Let's begin by looking at how the compiler treats a constant qualifier to begin with. If I write the following:

const int ci = 3;

The specification doesn't generally dictate how to implement a variable beyond the size of the variable type. On the usage of this variable, the compiler has a few options and those depending on configuration and optimization parameters.

So the compiler could generate Assembly code along these lines:

PUBLIC ci

_DATA SEGMENT

i DD 03H 
_DATA ENDS

Which reserves and places 0x03 in a write able memory location.

It could also optimize and produce:

PUBLIC ci

CONST    SEGMENT

i DD 03H
CONST ENDS

This puts the variable into a read only memory segment. If the compiler does this, then no matter the actual end Assembly, a fault will be thrown by the OS as it attempts to write to a read-only memory.

The C++ specification does not limit an implementation to rendering a constant object into read-only memory. If it did, you couldn't write code like the following:

const int ci = foo();

So the first challenge - ensure that our constant is not defined to reside in read-only memory, but instead resides in a writeable memory location.

The compiler could also optimize the object and just substitute the value directly.

int j = ci + 6;

becomes:

int j = 3 + 6; //or int j = 9;

Challenge two - ensure the implementation uses the constant as an actual memory located variable and not just a substituted value.

Ok, but what about assignment? The C++ compiler won't even generate code if we make an assignment to a constant qualified object. It will just generate an error and quit. You could attempt to overload the assignment (=) operator but that won't work for built-in types. The only viable option is to pass the constant object over to a function and have that implement the work. To conform to the specification, we must pass the object so that it qualifies as "undefined" instead of "ill-formed" or "illegal".

We've already seen examples of this. You would want a function that resembles this:

changeconst (int &var_to_change, int value_to_change_to)
{ 
    var_to_change = value_to_change_to;
}

and then call it along a line like this:

const int x = 0;
changeconst(const_cast<int&>(x), 42);

This places us in our "undefined" state. So challenge three is to implement this function.

You might believe that if you handle the variable instantiation in writeable memory that you could just do this last bit as illustrated in C++. And for the most part, you would be correct, but per the specification, this is undefined and so based on the compiler algorithm, there are instances where the compiler will optimize away the value exchange and the variable will not be updated as expected. Plainly stated, there are times when the compiler won't call the function.

Challenge four is to make sure the assignment function actually happens.

Writing the Code

Let's begin tackling each challenge. Again, the object is to comply with the specification but implement the ability to initialize a constant object after the initial declaration.

To do this, we are going to write the Assembly code we wish the C++ compiler would generate and then let the linker do its work in assembling the object files into our final executable. Note: Because there are differences in data declarations and ABI calling conventions between x86 and x64 assembly, we will need two assembly files (in this article referred to as changeconst.asm and changeconstx64.asm). Include the appropriate file for the architecture you require.

For the following illustrative examples, we will operate under the assumption that the developer is working with a Windows application and wants access to lpCmdLine from the WinMain or wWinMain Windows entry point. lpCmdLine will provide some information that will be used elsewhere. Its usage in this context requires a globalized variable declaration so that it could be used in other thread contexts. The developer made a design decision that he wants to protect the command line choice and not allow a modification of that data elsewhere in the code. He wants to guarantee this by using compile-time checking. (Note: arguments could be made for or against this design choice, this is just illustrative). The developer will use a namespace (App) and the variable LPWSTR CmdLine.

He now begins implementing the code and solve the challenges.

Challenge 1: Object Must be in Writeable Memory

We do not want the C++ compiler to define or initialize the variable because we do not trust it to generate code where the object is in writeable memory. This necessitates the use of the C++ keyword extern. This keyword indicates to the C++ compiler that this variable will be defined and initialized elsewhere – which in this case will be our Assembly code.

So in the C++ header file:

namespace App{ extern const LPWSTR CmdLine; }

CmdLine still needs to be defined and initialized, but the C++ compiler doesn't care where that happens now, so long as the linker finds it in some obj file. That obj file will be generated by our assembler (and in this article, I will be referencing the Assembly using MASM standards) and our Assembly code.

We want to declare the variable in writeable space. We do so with .data keyword (or _DATA SEGMENT). For this example, we will also give it an initial value of '0'. It can be any value that is compliant with the underlying data type.

Because the variable will be accessible outside of the Assembly, we also must reference the variable as having external scope (equivalent to the C++ extern). We must add a PUBLIC declaration in the Assembly code.

In changeconst.asm, we begin with the following:

;changeconst.asm
.686P
.model flat

PUBLIC ?CmdLine@App@@3QA_WA

.data 
?CmdLine@App@@3QA_WA DD 00H

changeconstx64.asm

;changeconstx64.asm
PUBLIC ?CmdLine@App@@3QEA_WEA

.data 
?CmdLine@App@@3QEA_WEA DQ 0000000000000000H

Note: In this example, we present "CmdLine" with name mangling. If you wish to not use name mangling, use extern "C". If the rest of the code will be C++, you can preserve namespace scoping for this variable and change function and just reference the variable and function with the proper mangled declaration. A trick is to declare the items external and then compile. The linker should give an error that it could not find an external definition and give the mangled name - just cut and paste!

At this juncture, CmdLine is to the assembler a standard changeable variable, but to the C++ compiler, it is a const qualified variable and will be treated as such in the C++ code.

Challenge 1 met. We are still compliant with the C++ specification.

Challenge 2: Ensure No Substitution of Value

By solving Challenge 1, we also solve Challenge 2. Because the variable is declared extern, the C++ can't make a substitution as it doesn't have a known value. It will treat the object as an object and not an optimizable value that can be directly substituted.

Challenge met and we are still compliant.

Challenge 3: Implement the Assignment

Now to write the function to change the variable.

As already alluded to, you can actually write this in C++. You can do this because the actual C++ isn't anything special. In fact, the function can be optimized to an extremely efficient assembly that looks like this (in x64):

mov         qword ptr [App::lpCmdLine (013FA3F004h)],rbx 

There are a few approaches. You can take a generalized approach and write one function:

changeconst(void *var, void *value)
{
    *var = *value;
}

This has the advantage of taking any type and there is just one function. The trade-off is you lose any type checking.

If you want to allow for type checking, write a function for each type you need:

changeconstint(int &var, int value)
{
    var = value;
}

changeconstfloat (float &var, float value)
{
    var = value;
}

You get type checking but more code. Resolve this by using a template:

template<typename T>
void changeconst(T &var, const T value)
{
    var = value;
}

And you call the function:

int APIENTRY wWinMain(_In_ HINSTANCE hInstance, _In_opt_ HINSTANCE hPrevInstance, 
                      _In_ LPWSTR lpCmdLine, _In_ int nCmdShow)
{
    App::changeconst(const_cast<LPTSTR&>(App::CmdLine), lpCmdLine); 
    ...
}

Note, because the prototype for changeconst includes two non-constant variables, we force the developer to consciously cast away "const"ness. The developer has to say explicitly - I know what I am doing and I want to change the value of this constant variable. This is also a signal to anyone reading the code to pay attention - something is happening here.

This will produce the desired Assembly code. But it doesn't do one thing - it doesn't prevent the compiler from thinking that since this is a constant object it can optimize and ignore this function call.

One way to deal with this is to use the volatile qualifier. Per the specification:

Quote:

volatile is a hint to the implementation to avoid aggressive optimization involving the object
because the value of the object might be changed by means undetectable by an implementation. Furthermore,for some implementations, volatile might indicate that special hardware instructions are required to access the object. See 1.9 for detailed semantics. In general, the semantics of volatile are intended to be the same in C++ as they are in C.

By using volatile hinting, we can signal to the compiler that the variable might change and to avoid optimizations. Generally, this works. We modify our earlier declaration to be thus:

namespace App{ extern const volatile LPWSTR CmdLine; }

Have, we met our Challenge 3? Maybe. First, the specification doesn't really spell out what avoiding "aggressive optimization" means. volatile may allow us to use our C++ function as written and have the intended results, but then again it may not. There's no guarantee, and there's no guarantee from release to release.

Also, does using the C++ function as we've written comply with the specification. Maybe, but that's not really clear. This is by the spec still "undefined" behaviour. Have we defined the behaviour? Looking at the compiled Assembly, it would appear, yes.

So if you feel comfortable, then proceed with the above. Otherwise, we do have a method to guarantee the results regardless of volatile and stay within specification.

I will note that volatile should still be used. The value of the object will change in undetectable ways to the C++ compiler. It also signals to any developer reading the code that the value might change. So the rule should be to remain in specification, use volatile and if you feel comfortable, use the C++ function template.

This also leads us into our Challenge 4.

Challenge 4: Ensure Our Assignment is Called

Continuing with our guaranteed specification compliant assignment function, we go back to generating our own Assembly code for the compiler.

In the C++ header:

namespace App { 
    extern void __fastcall changeconst(void *var, void *value);
}

The function has two parameters – the const variable to be changed, and a variable containing the value to change to. Here, I am showing a universal function that will take any type of variable. You can change the prototype and replace void with the appropriate variable type, just as in the above example. This gives the advantage of preserving type checking while sacrificing simplicity as you need to create a separate change function for each type of constant in the Assembly. Since the variables can be any type, the function uses void and you can only reference void by pointer, not by value in C++.

Because this function is extremely simple, I use __fastcall in the prototype. __fastcall is an MSVC keyword specifying the type of calling convention. This is only applicable for x86 architecture, as x64 only uses __fastcall. Other compilers use the same or have an equivalent declaration. When using _fastcall, per MSDN, the first two DWORD or smaller arguments that are found in the argument list from left to right are passed in ECX and EDX registers; all other arguments are passed on the stack from right to left. CAUTION: The ecx and edx registers are not guaranteed to be the same in future versions of MSVC.

The Assembly will not be doing anything with the stack or any of the protected registers so __fastcall makes sense, especially considering that the C++ compiler will not be doing any optimization on this front.

In changeconst.asm, add:

.code
PUBLIC ?changeconst@App@@YIXPAX0@Z 
?changeconst@App@@YIXPAX0@Z PROC
    mov eax,dword ptr [edx]  ;edx is the address for the value - dereference and move to eax
    mov dword ptr [ecx], eax ;ecx is the address for the variable - store value in eax in variable 
    ret
?changeconst@App@@YIXPAX0@Z ENDP 

END

If you do not want to use __fastcall, instead add the following to changeconst.asm:

.code
PUBLIC ?changeconst@App@@YIXPAX0@Z
?changeconst@App@@YIXPAX0@Z PROC
    push ebp					 ;prologue
    mov  ebp,esp
    sub  esp,0C0h
    mov  edx,dword ptr [ebp+12] ;move value to edx
    mov  ecx,dword ptr [ebp+8]  ;mov const variable's to change address to ecx
    mov  eax, [edx]			    ;dereference the value in edx and mov to eax
    mov  dword ptr [ecx],eax    ;move value in eax to variable dereferenced in ecx
    mov  esp,ebp                ;epilogue
    pop  ebp
    ret
?changeconst@App@@YIXPAX0@Z ENDP

END

For x64, we do not need to worry about calling convention so there's one code. Add the following to changeconstx64.asm:

.code
PUBLIC ?changeconst@App@@YAXPEAX0@Z
?changeconst@App@@YAXPEAX0@Z PROC
mov rax,qword ptr [rdx]  ;rdx is the address for the value - dereference and move to rax
mov qword ptr [rcx], rax ;rcx is the address for the variable - store value in rax in variable
ret
?changeconst@App@@YAXPEAX0@Z ENDP

END

Finally in the C++ code, you can change your const variable like this:

wWinMain(_In_ HINSTANCE hInstance, _In_opt_ HINSTANCE hPrevInstance, 
         _In_ LPWSTR lpCmdLine, _In_ int nCmdShow)
{
 App::changeconst(&const_cast<LPWSTR&>(App::CmdLine), &lpCmdLine);
 ...
}

We meet our Challenge 3 and 4. We've generated the assignment function and guaranteed that it won't be optimized away. We also meet the specification. Using volatile and by moving the object assignment to a function declared externally in our Assembly doesn't violate any rules.

You can feel satisfied that you have an object that can be qualified const and be initialized shortly after the object is declared. True, you can abuse this and just keep making assignments, but you have to ask wouldn't it be easier to just remove the qualifier? Is it really a constant?

This code is simple and straightforward and if well documented, should not bite any developer now or in the future. Also, it's compact enough that it should be maintainable even in a large project.

History

  • 1.0 - Initial release
  • 1.1 - Added a small change to include volatile hinting
  • 2.0 - Major rewrite to address concerns brought up in the initial comments. Hopefully, intent and use case is more clear

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)

Share

About the Author

Jon Campbell
Architect
United States United States
I am a Solution Architect for IU Health architecting software solutions for the enterprise. Prior I had been employed with eBay as a Project Manager and Lead (Enterprise Architect) which focused on managing the scope, timeline, resource, and business expectations of third party merchants and their first-time onboarding to the eBay marketplace. I also acted as an Enterprise Architect leading the merchant in the design, reliability, and scaling of their infrastructure (both hardware and software). Prior I worked for Adaptive Computing as a Sales Engineer. I was responsible for working with customers and helping with the sales life cycle and providing input to Program Management. Prior I was employed as a High Performance Computing Analyst. I was responsible for administering and maintaining a high performance / distributed computing environment that is used by the bank for financial software and analysis. Previous, I had been employed at ARINC, where as a Principal Engineer I worked in the Systems Integration and Test group. The division I worked in represented the service end of the airline communication network. Prior to this position I worked for the government in several contractual roles which allowed me to lead a small team in consulting and evaluating research initiatives and their funding related to discovering and negating threats from weapons of mass destruction. Other contracts included helping the Navy in a Signals Intelligence role as the lead hardware and systems architect/engineer; a Senior Operations Research Analyst assessing force realignment and restructuring for the joint Explosive Ordnance Disposal community with a project assessing the joint force structure of the EOD elements, both manning, infrastructure, and overall support and command chain structures. I also spent three years involved with the issue of Improvised Explosive Devices and for the Naval EOD Technology Division where I acted as the lead engineer for exploitation.

You may also be interested in...

Pro
Pro

Comments and Discussions

 
General[My vote of 1] Is This For Real? Pin
Jeff.Bk9-Jan-18 11:41
memberJeff.Bk9-Jan-18 11:41 
GeneralRe: [My vote of 1] Is This For Real? Pin
Jon Campbell10-Jan-18 3:35
professionalJon Campbell10-Jan-18 3:35 
Question[My vote of 2] bad advice Pin
Stefan_Lang9-Jan-18 4:06
memberStefan_Lang9-Jan-18 4:06 
AnswerRe: [My vote of 2] bad advice Pin
Jon Campbell10-Jan-18 3:50
professionalJon Campbell10-Jan-18 3:50 
GeneralRe: [My vote of 2] bad advice Pin
Stefan_Lang10-Jan-18 4:48
memberStefan_Lang10-Jan-18 4:48 
GeneralRe: [My vote of 2] bad advice Pin
Jon Campbell10-Jan-18 7:54
professionalJon Campbell10-Jan-18 7:54 
GeneralRe: [My vote of 2] bad advice Pin
Stefan_Lang10-Jan-18 21:29
memberStefan_Lang10-Jan-18 21:29 
GeneralRe: [My vote of 2] bad advice Pin
Jon Campbell11-Jan-18 4:31
professionalJon Campbell11-Jan-18 4:31 
GeneralRe: [My vote of 2] bad advice Pin
Jon Campbell11-Jan-18 13:52
professionalJon Campbell11-Jan-18 13:52 
GeneralRe: [My vote of 2] bad advice Pin
Stefan_Lang14-Jan-18 20:54
memberStefan_Lang14-Jan-18 20:54 
QuestionTo guarantee the variable update ... there is the keyword "volatile". Pin
le_top19-Dec-17 7:54
memberle_top19-Dec-17 7:54 
AnswerRe: To guarantee the variable update ... there is the keyword "volatile". Pin
Jon Campbell19-Dec-17 9:43
professionalJon Campbell19-Dec-17 9:43 
AnswerRe: To guarantee the variable update ... there is the keyword "volatile". Pin
Jon Campbell8-Jan-18 7:05
professionalJon Campbell8-Jan-18 7:05 
QuestionInteresting article, but... Pin
Daniel Pfeffer16-Dec-17 23:54
professionalDaniel Pfeffer16-Dec-17 23:54 
AnswerRe: Interesting article, but... Pin
Jon Campbell18-Dec-17 3:36
professionalJon Campbell18-Dec-17 3:36 
AnswerRe: Interesting article, but... Pin
Rick York19-Dec-17 10:08
memberRick York19-Dec-17 10:08 
GeneralRe: Interesting article, but... Pin
Jon Campbell3-Jan-18 5:33
professionalJon Campbell3-Jan-18 5:33 

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.

Permalink | Advertise | Privacy | Cookies | Terms of Use | Mobile
Web03 | 2.8.180712.1 | Last Updated 8 Jan 2018
Article Copyright 2017 by Jon Campbell
Everything else Copyright © CodeProject, 1999-2018
Layout: fixed | fluid