Statically Checking Exception Specifications

Statically Checking Exception Specifications

By Ken Hagan

Overload, 11(57):, October 2003


The C++ newsgroups occasionally have threads about "fixing" exception specifications (hereafter "ES") so that they can be checked at build-time. One practical problem is maintenance; an ES depends on all the callees of a function as well as the function itself. A more fundamental problem is that the exceptions that can be thrown from templated code can vary with the instantiation parameters and these are clearly unknowable when the programmer writes the template. Perhaps the programmer is the wrong person to be writing the ES.

Outline of a Solution

As the language stands right now, a function with no ES can throw anything. My basic change is to say that when a function has no explicit ES, the programmer wants the build system to deduce one.

Except where dynamic linking is used, the compiler can determine the most restrictive ES that the programmer could have written for the function, by noting which exceptions are thrown and caught and which functions are called. Since source code isn't generally available for the called functions, the calculation cannot be completed, but it can be reduced to an ES-expression. For example, for this function the compiler might emit ES(Foo)=ES(Baz)-Quux+Oink .

void Foo() {
  try { if(Baz()==42) throw Oink(); }
  catch (Quux& b) { /*stuff*/ }
}

The linker then considers each function in turn, replacing expressions with an absolute ES wherever possible. If not every expression is resolved on the first pass, it makes another pass and so on until completed. When compiling templates, the compiler can emit ES-expressions that depend on template parameters. When instantiating those templates (perhaps in a pre-link phase) the expressions can be converted to non-dependent expressions.

The ES-expressions may be stored in a separate file, in the object file using some extension, or in the object file as an un-nameable data item that the linker is sure to discard. The first is cumbersome, the second might conflict with an ABI and the third is a filthy hack, but all three are workable. For static libraries, library code is no different from our own as far as the linker is concerned.

To eliminate false positives we need a new cast: the nothrow_cast . It operates on pointers to functions; so it modifies individual calls rather than the definitions. It tells the compiler that this invocation of the function will not throw the specified type. As usual with casts, if you lie to the compiler then it will get its revenge in the form of implementation defined or undefined behaviour.

Four complications

1) Pointers to Functions

We expect to be able to write an expression for the minimal ES involving only class names and the ES-es of named functions. With function pointers, we don't know which function they point to. We can, however, identify the pointer itself. It must be one of the following 4 cases, and each can be named.

  • A global variable, such as baz (in the example below).

  • A struct or class member, such as Quux::m_pfn .

  • A function parameter or return value, such as Foo(4th) .

  • A local (automatic) variable, such as Foo()::name .

For structure members, and function parameters, no attempt is normally made to distinguish between different instances or invocations. The local variable case can be eliminated by the compiler because ES(Foo()::name) can always be replaced with the ES of whatever was used to initialise name, but that's just an optimisation.

What then? Well firstly, we can write the minimal ES for a function that uses such a pointer, simply referring to the ES of this named item.

extern int (*baz)();
void Foo() {
  try { if((*baz)()==42) throw Oink(); }
  catch (Quux& b) { }
}

For this function, ES(Foo) = ES(*baz)-Quux+Oink . Great, as long as the linker can figure out ES(*baz) . That's slightly harder than ES(Baz) , because, informally, Baz is a constant but baz is a variable. However, we can model (*baz)() as a function that calls all of the functions that are assigned to baz throughout the program, and each of those assignments will be seen by the compiler. For each assignment, the compiler can spit out ES(*baz) += ... , where the right hand side is either ES(Function) or ES(*another_pointer) .

All the linker has to do is join all the pieces together. The linker has an ES for a named object that possibly depends on the ES-es of other named objects. Pointers to functions are now no different from the functions themselves, and they all get thrown into the pot and resolved together.

Pointers to pointers to functions, such as virtual function tables, add little new to the problem. Instead of tracking everything that (*p)() might point to we have to track everything that (**pp)() might point to.

2) Recursion

In the presence of recursion, the call graph of a program has cycles. For such functions we might have ES(Foo) = +Quux - Oink + ES(Foo) . Now, a function neither increases nor decreases its ES by calling itself, so when ES(Foo) appears on the right hand side of an expression for itself it can be ignored. This allows us to break the cycles in recursive systems.

Though we can ignore ES(Foo) for itself, we cannot ignore it elsewhere in the cycle. Part of the cycle might throw exceptions that are caught by other parts of the cycle, so the minimal ES exposed from a recursive cycle depends on where you enter it.

In fact, this property that lets us evaluate ES-expressions in any order we like. We can just pick one and recursively replace every term on the right hand side with its expansion. Eventually we will have a long expression with either absolute classes or repetitions of the left-hand side. We remove the latter and we have our absolute ES.

Shared Libraries

For shared libraries, the linker doesn't see the actual code when it is linking the client application. Whether this is a problem depends on how the linking is achieved. I'm familiar with Windows, so I'll treat the two cases on that platform and then ignorantly assert that other platforms add nothing new.

The first case is load-time dynamic linking. The linker is given an "import library" which describes the data and functions exposed by the DLL. Any references to those are replaced with placeholder items in the linked application and the operating system loader "fills in the blanks" when the application is loaded into memory to run. The provision of an import library makes this case very similar to the static case. I believe it is sufficient for the import library to contains ES-expressions for just those items mentioned in the library's header file, since that it all the compiler sees and so that is all that can appear in client object files. This is certainly the case in my extended example.

The second case is run-time linking, where the program uses some magic to conjure an address out of the ether. To take a slightly non-trivial example...

extern IFoo* CreateSuperFoo();
        // in external library
IFoo* (*pfn)() = /*magic*/;
        // in client code
IFoo* p = (pfn)();

The details of CreateSuperFoo and also of whatever IFoo derived class that this library actually offers is a complete mystery to the build system. It may be written in a different language so it is quite possible that neither the compiler nor linker ever see it. Here we have the one place where I think a programmer has to write an ES.

The two main objections to ES that I noted at the beginning of the article don't apply. A dynamically loaded extension cannot be a template, though it may be an instantiation. Neither is it likely to change often and even if it does, all knock-on effects on the rest of the system are now the compiler's problem.

False Positives

The final problem is that a function might throw an exception in the case of bad input, and carry an ES to that effect, but many callers might never feed bad parameters into the function. This partly depends on one's programming style. If I might return to the example...

if(x<0) return false;
x = Sqrt(x); // assuming Sqrt() throws
             // when x<0

There are certainly situations where one should write one of the following...

Type Sqrt(Type x) { if(x<0) abort(); ... }
Type Sqrt(Type x) { assert(x>=0); ... }

...and happily spit in the faces of irate clients whose programs were aborted, saying "Don't do that then!". However, if we choose to throw an exception instead then our clients will either be faced with link-time ES errors or be forced to write such abominations as

if(x<0) return false;
try { x = Sqrt(x); } catch (...) {
/*unreachable*/ }

Not only does this look bad, but it probably incurs run-time penalties (space and time). As with the function pointers, we know something that the compiler doesn't, so we tell it with a cast.

if(x<0) return false;
nothrow_cast<std::logic_error>(Sqrt)(x);

The nothrow_cast tells the compiler that the function does not throw the mentioned type.

Costs and Benefits

I think it is worth confessing at this point that I've only spent the time and energy on this because I wanted static checking. Showing that it could be done with minimal impact on existing source code seemed like a good way to argue the case. It all turned out a little harder than I expected, so is static checking worth this effort?

First, I note that the current standard allows ES violations at runtime, so any ES violations detected by this system can only result inlinker warnings. The linker must still generate a working executable.

Costs and Limitations

The scheme derives the minimal ES from whatever source code is presented to it, so the same program might "fail" if compiled against StlPort rather than Dinkumware, or if compiler settings change. A debug build might give false positives that an optimising build can rule out as a by-product of its analysis.

You and your library vendors will all have to run all the code through the newcompiler. The scheme adds no new compile-time errors, so if the library vendors are still in business then theyshouldn't have much of a problem with this.

If you don't modify the code then you may get warnings from the ES checking phase, which will disable the various optimisations mentioned below. You've lost nothing except for the extra build costs.

If you are able to modify your code, you can eliminate all the errors using explicit ES and throw_cast , respectively. In both cases you can let the diagnostics guide you. There is no problem of figuring out what changes to make, simply the time involved in actually doing it.

The extension does require more complicated compilers and linkers. I can't judge how much more complicated because I've never written a compiler or linker, let alone one for C++. There is also a cost in build time which I don't feel qualified to estimate, but I have already noted that we don't need to reduce ES expressions in any particular order.

Benefits

Having to treat ES violations as warnings actually yields a couple of migration paths. A vendor could just ignore the whole idea, implementing the nothrow_cast as a do-nothing template function. Equally, since there is no new run-time behaviour, the whole thing could be done by a tool like lint.

If we can spot violations of throw() at build-time rather than run-time, with any tool, the Abrahams exception safety guarantees are easier to police. The cast may be useful to the compiler even without the link-time checking, since it can optimise more strongly if it believes exceptions can't happen.

However, we get maximum benefit if the compiler and linker do the checking. The cost of exceptions that cannot ever occur can be reduced to zero in both time and space and any function that can't throw (and all its immediate callers) can be recompiled with that knowledge. With these optimisations, Standard C++ is more attractive for embedded systems and vendors needn't include compiler options to disable exceptions.

Consider the common scenario of an interface header file...

struct IFoo {             // struct IFoo::vbtl {
  virtual void Bar() = 0; // void (*pBar)(IFoo*);
  virtual int Quux() = 0; // int (*pQuux)(IFoo*);
};                        // };
void AddFoo(IFoo*);
void DoStuff();

...used by a shared library source file...

IFoo* global;            // IFoo::vtbl** global;
void AddFoo(IFoo* foo)
  { global = foo; }      // ES(AddFoo) = 0
                         // ES(*IFoo::vtbl.pBar) += ES((*AddFoo 1st).pBar)
                         // ES(*IFoo::vtbl.pQuux) += ES((*AddFoo 1st).pQuux)
void DoStuff()
  { global->Bar(); }     // ES(DoStuff) = ES(*IFoo::vtbl.pBar)

...and implemented in an application source file...

class Foo : public IFoo {
  virtual void Bar()     // ES(*IFoo::vtbl.pBar) += ES(Foo::Bar)
    { throw 1; }         // ES(Foo::Bar) = int
  virtual int Quux()     // ES(*IFoo::vtbl.pBar) += ES(Foo::Bar)
    { return 0; }        // ES(Foo::Quux) = 0
};
int main() {
  AddFoo(new Foo);       // ES( *(*(AddFoo 1st).pBar) ) += ES(Foo::Bar)
                         // ES( *(*(AddFoo 1st).pQuux) ) += ES(Foo::Quux)
  DoStuff();
}                        // ES(main) = ES(AddFoo) + ES(DoStuff)

In the application, the compiler can see that the IFoo* parameter to AddFoo is actually a " new Foo ". Had that detail not been visible, the compiler could only have written...

ES( *(*(AddFoo 1st).pBar) ) += ES(*IFoo::vtbl.pBar)
ES( *(*(AddFoo 1st).pQuux) ) += ES(*IFoo::vtbl.pQuux)

We bring all this together in the linker. Our raw data from compiling the library is...

ES(*(IFoo::vtbl->pBar)) += ES(*(AddFoo 1st)->pBar)
ES(*(IFoo::vtbl->pQuux)) += ES(*(AddFoo 1st)->pQuux)
ES(DoStuff) = ES(* IFoo::__vtable.pBar)
ES(AddFoo) = 0

That from compiling the application is...

ES(*IFoo::vtbl.pBar) += ES(Foo::Bar)
ES(Foo::Bar) = int
ES(*IFoo::vtbl.pQuux) += ES(Foo::Quux)
ES(Foo::Quux) = 0
ES(*(AddFoo 1st)->pBar)) += ES(*IFoo::vtbl.pBar)
ES(*(AddFoo 1st)->pQuux)) += ES(*IFoo::vtbl.pQuux)
ES(main) = ES(AddFoo) + ES(DoStuff)

Bringing it all together and substituting yields...

ES(main) = 0 + ES(DoStuff)
         = ES(*(AddFoo 1st)->pBar)
         = ES(*IFoo::vtbl.pBar)
         = ES(Foo::Bar)
         = int





Your Privacy

By clicking "Accept Non-Essential Cookies" you agree ACCU can store non-essential cookies on your device and disclose information in accordance with our Privacy Policy and Cookie Policy.

Current Setting: Non-Essential Cookies REJECTED


By clicking "Include Third Party Content" you agree ACCU can forward your IP address to third-party sites (such as YouTube) to enhance the information presented on this site, and that third-party sites may store cookies on your device.

Current Setting: Third Party Content EXCLUDED



Settings can be changed at any time from the Cookie Policy page.