Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles / Languages / C#

Near-compile-time Checks

4.83/5 (5 votes)
12 Feb 2015CPOL7 min read 22.6K   74  
If compile-time assertions are not enough, this is the next best thing

Introduction

Software development shouldn't be like rolling a die. If you press F5, your program should run, and it shouldn't just crash to a debugger.

The thing I most like about C++ is the way you can use it to create compile time checks for your code. In the hands of a good developer, you can check a lot of things even before you start your program.

When Microsoft launched Generics in .NET 2.0 about 10 years ago, I missed the flexibility of template meta-programming and the way you can use it to add compile-time constraints to your code, even though I completely understand why. However, my way of writing code and my strong urge to implement compile time checks hasn't vanished. Unfortunately it Just Isn't Possible in many cases.

Ultimately, the trade-off between compile time checks and runtime checks let me to come up with this next-best-thing alternative.

Background

What you basically want to do with compile-time checks is to ensure that a certain class (or method) has a certain behavior. The trick of compile-time checks is to express a behavior into a type, which is then checked by the compiler. Most of these checks are checks if something (like a method) exists. In C++, the way to do this is through template meta-programming, which is well discussed on various sources on the web.

The important thing to notice here is that it's not about the actual types that you want to check. Things like type lists are just a tool to ensure certain behavior will follow when you start using them.

Moving Towards the C# World

In .NET, a lot of behavior is checked in your program. What we basically do is test if a condition is true by 'asserting'. If false, an ugly exception is thrown and our execution is aborted.

The advantage of this is that compile time and code complexity is reduced considerably. The disadvantage is that you need thorough test code to ensure that your program is correct. Being a professional software developer, I unfortunately rarely encounter projects with thorough test code in production systems.

Unlike test code, runtime checks are quite common. Most senior-level software developers make it a habit to check all assumptions before acting on them. However, these checks will evaluate during execution, which will trigger a runtime error -- and if proper integration tests are unavailable, this usually means your software is going to crash when your customer is playing with it.

What we need is a new type of assertion, that runs as close as compile-time as possible.

Enter near-compile-time Assertions

Near-compile-time (NCT) assertions (as I call them) are assertions that are triggered the moment the program is loaded.

NCT assertions rely on static code analysis and reflection to check your code for the things that aren't checked compile-time. It is possible to trigger NCT assertions during your build process or during your testing, and disable them in release mode.

NCT uses attributes to give you the correct behavior. Types, members, etc. are checked using reflection. However, this doesn't give you all the behavior you might need, so an IL decompiler checks the method body.

NCT has no overhead on the runtime; only start-up time is affected. There is however one exception to this rule: if you use static constructors, they will be triggered as a result of reflection. Also, the current implementation limits the assemblies that are processed through NCT; this is to ensure not all your referenced libraries are checked (which will give you a performance hit during startup).

Note that NCT cannot check everything! If a type is created using RTTI (e.g. "Type.GetType(name)") the type cannot be deduced before execution, hence it cannot be checked.

Using NCT assertions

Using NCT time assertions, you can do things like this (note the bold code - HasContructor is an attribute):

C#
    public interface IMyInterface
    {
        void Execute(); // or whatever code you have
    }

    // Check if a class has an integer constructor
    public class CtorTest<[HasConstructor(typeof(int))]T> where T : IMyInterface
    {
        public IMyInterface Create(int i)
        {
            // This code won't fail (unless you use RTTI or call it from another assembly).

            var instance = (IMyInterface)Activator.CreateInstance(typeof(T), new object[] { i }); 
            return instance;
        }
    }

    // ...

    class Program
    {
        static Program()
        {
            // Perform all runtime checks (on this assembly only)

            new NCTChecks(typeof(Program));
        }

        static void Main(string[] args)
        {
            // Normal operation

            Console.WriteLine("Your normal code");
            Console.ReadLine();
        }
    }

How does NCT work internally?

If you define a type and make assumptions about how a developer uses this type, you usually assert if the assumption is correct, and in most cases this assertion won't hit. For example, if you implement a builder pattern (e.g. something like the 'CtorTest' above), you check in an assertion if the type has the correct constructor parameters, and fail the assertion if it does not. In code this will look something like this:

public class CtorTest<T> where T : IMyInterface
{
    public IMyInterface Create(int i)
    {
        // Check if the constructor exists
        if (typeof(T).GetConstructor(new Type[]{typeof(T)}) == null)
        {
            throw new SomeFatalException("The constructor is not defined on the type you passed. Cannot continue.");
        }

        // This code won't fail (unless you use RTTI or call it from another assembly).
        var instance = (IMyInterface)Activator.CreateInstance(typeof(T), new object[] { i });
        return instance;
    }
}

If you test your code and find out that the exception is triggered, you know you've made a bug. However, this requires you to test your code; it would be better to know that the parameter is correct. The .NET compiler can of course already do this with simple generic constraints like 'new()' and 'class', but doesn't more complex usage of types.

Fortunately for us, most code in software is pretty predictable. In this case, we can check if the code is correct by checking all the types that are being used throughout the entire program. After all, if a type isn't being referenced compile-time, it's very unlikely that it is being used run-time. In more detail, the relation between types being used and types being referenced is as follows:

  1. Type is being used in a type, e.g. a field, class, property, method parameter, return type or base class. This is the most common usage scenario.
  2. Type is being used in a method body. This is also very common.
  3. Type is being parameterized at run-time. Quite uncommon in most applications.
  4. Type is being constructed using run-time type information (RTTI). You should avoid this where possible; very uncommon in most applications.

Here are some examples of all these cases to show how this works in code:

// Case 1: Type is being used in a type (2 examples):
public class TestClass : SomeClass<IMyInterface> { ... }

public class TestClass
{
    SomeClass<IMyInterface> myMember; // or a property, method, etc.
}

// Case 2: Type is being used in a method body:
void Test() {
    new SomeClass<IMyInterface>();
}

// Case 3: Type is being parameterized at run-time
typeof(CtorTest<>).MakeGenericType(typeof(IMyInterface))

// Case 4: Type is being constructed using RTTI
Type t = Type.GetType("CtorTest`1[IMyInterface]");

NCT assumes your code uses the (common) cases 1 and 2. If you use either case 3 or 4, it won't be detected by NCT assertions, and should be checked by normal assertions. (I should note here that I think case 3 could even be detected by NCT; still, I didn't really make an effort because IMHO it's quite rare).

Actually detecting if your code uses these cases means that we should analyze all types and code at run-time. In a nutshell, this is exactly what the code of NCTChecks does:

  • The application registers a type or an assembly. The NCTChecks code will analyze all these types.
  • [case 1] For each type, all types that are used (including generic constraints, base classes, fields, etc) are marked for analysis. This is basically what I call 'Phase 1', which will travel all types being used.
  • [case 2] If we find a method (method / constructor / property), we need to figure out what types are used in the method body. By decompiling the method body, we can figure out which types are being used, which are added to the analysis queue as well.

During type traveral, we have a lot of information about the (global) context of a method at our disposal. I currently use only a very small part of the information for NCT assertions, but basically this is what you have:

  • All type information used in an assembly (e.g. case 1 and 2 described above) - which also means: all type parameters of all generics that are used
  • All the methods that call a method (e.g. flow information)
  • Information about jumps and exceptions in the method bodies

The current NCT implementation uses only the first type of information to check additional NCT assertions put on generics. The check itself is very easy, and is performed every time we detect a generic type.

Future of NCT

NCT's are still being developed; this is just the first glimpse of the possibilities.

In the future I'd like to add support for more complex flow analysis that can proove certain things are true (f.ex. if an array has certain bounds for all 'analyzed' code paths and to check if we can proove that an argument/field variable is never null).

Closely related to this is the possibility to speed up compiled .NET code considerably: by inlining certain functions and by changing 'callvirt' IL calls to 'call'. Still, this is still a long way from there currently.

History

  • 12 Feb 2015 - Initial version
  • 13 Feb 2015 - Added more information about how this works

License

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