When working with C++ templates, developers often notice curious compiler behaviors regarding unused code. If a template contains a call to a non-existent function inside an uninstantiated member function, why do some compilers accept it while others fail with errors like -Wtemplate-body? In this article, we'll dive into what the C++ standard says about ill-formed code inside templates, two-phase name lookup, and how modern compilers handle these scenarios.

The Problem Example

Consider the following C++ code compiled under the C++17 standard:

template <typename T>
struct Foo {
    Foo() {}
    Foo(T) {
        this->bar(); // 'bar' does not exist in Foo
    }
};

int main() {
    Foo<int> foo1; // Instantiates Foo<int>::Foo(), but NOT Foo<int>::Foo(int)
}

In this snippet, Foo<int> is instantiated using the default constructor. The single-argument constructor Foo(T) is never called or instantiated. Yet, compiling this code across different compilers or GCC versions yields very different results:

  • Older GCC / Permissive Mode: Compiles without errors or warnings.
  • GCC 15+ (Default Settings): Fails with error: 'struct Foo<T>' has no member named 'bar' [-Wtemplate-body].
  • GCC 15+ with --permissive: Emits a warning but compiles successfully.

Is a Compiler Allowed to Ignore Errors in Unused Template Bodies?

The short answer is yes, but with major caveats defined by the C++ Standard. To understand why, we need to examine two core standard concepts: lazy instantiation and Ill-Formed, No Diagnostic Required (IFNDR).

1. Lazy Instantiation of Member Functions

According to the C++ standard (specifically under section [temp.inst]), member functions and constructors of a class template are only instantiated when they are actually referenced or required in a context that requires their definition.

Because foo1 only invokes the default constructor Foo(), the constructor Foo(T) is never instantiated for T = int. In older or less strict compilers, checking for dependent names within uninstantiated functions was deferred entirely to Phase 2 (instantiation phase), so the error was never triggered.

2. Two-Phase Name Lookup and Dependent Names

In the expression this->bar(), the name bar is dependent on this, which itself depends on the template parameter T. Standard two-phase name lookup dictates:

  • Phase 1 (Template Definition): Syntax checking and non-dependent name lookup occur.
  • Phase 2 (Template Instantiation): Dependent names are looked up when the template is instantiated with concrete types.

Because this->bar() is a dependent expression, strict Phase 1 checks traditionally skipped resolving whether bar() existed until Phase 2. However, because Foo<T> has no base classes that could provide a bar() member function, it is mathematically impossible for this->bar() to ever be valid for any instantiation of Foo<T> (unless explicit or partial specializations are introduced elsewhere).

3. The IFNDR Rule in the Standard

The C++ standard addresses template definitions that can never be valid under [temp.res.general]:

If no valid specialization can be generated for a template definition, and that template is not instantiated, the program is ill-formed, no diagnostic required (IFNDR).

Key implications of this rule:

  • Since no valid instantiation of Foo<T>::Foo(T) could ever exist, the program is ill-formed.
  • The clause "no diagnostic required" gives compiler vendors the freedom to either issue an error/warning or completely ignore the issue during template definition time.
  • Compilers are not required to catch it, but they are fully within their rights to diagnose it and reject the code during Phase 1 parsing.

Why Modern Compilers Are Getting Stricter

Historically, compilers like GCC and MSVC skipped deep static analysis on uninstantiated dependent code to speed up compilation times. However, modern C++ compilers (including GCC 15 with -Wtemplate-body, Clang, and MSVC with /permissive-) perform early syntactic and semantic checks on template bodies.

When GCC 15 sees this->bar() inside Foo<T>, it analyzes the class definition in Phase 1. Recognizing that Foo has no dependent base classes from which bar() could be inherited, it immediately flags the code as invalid rather than waiting for an instantiation that will never come.

Conclusion

While legacy C++ compilers ignored ill-formed code inside uninstantiated template functions, standard C++ considers such code ill-formed (IFNDR). Modern compilers are increasingly implementing early template checking to catch bugs at definition time rather than at instantiation time. To write compliant and future-proof C++ code, always ensure template member functions are valid even if they aren't actively used in your immediate test cases.