Wilkening-Online Logo

C++ Funktions-Deklarationen und Objekt-Erzeugungen



Von Detlef Wilkening
26.04.2013
Version: 1

1. Unterscheide Funktions-Deklarationen und Objekt-Erzeugungen

Ist das wirklich einen Artikel wert - die Unterscheidung von Funktions-Deklarationen und Objekt-Erzeugungen? Das sind doch zwei sehr verschiedene Dinge, die schon ein Anfänger nicht durcheinander bringt, oder?

Wer so denkt, hat die Rechnung ohne den Wirt gemacht - hier in Form einer manchmal kruden C++ Syntax. Es gibt leider Situationen, die nicht so offensichtlich von vielen Programmieren erkannt werden.

1.1 Funktions-Deklaration statt Objekt-Erzeugung

Ein typische Fehler, den viele C++ Programmierer kennen, da er heute in jedem besseren C++ Buch oder Tutorial[1]. beschrieben ist, ist die fehlerhafte Initialisierung eines lokalen Objekts mit denn leeren runden Klammern - siehe folgendes Beispiel für das Symbol "a2".

class A
{
public:
   A();
   A(int);
};

int main()
{
   A a1;           // okay
   A a2();         // Syntaktisch auch okay, aber was ist das hier?
   A a3(5);        // okay
}

Scheinbar legt der Programmierer hier drei Objekte "a1", "a2" und "a3" vom Typ "A" an. Aber diese offensichtliche Interpretation ist bei "a2" falsch. Wenn Sie genau hinschauen, dann sehen Sie folgenden syntaktischen Aufbau:

Typ  Name  Klammer-auf  Klammer-zu

Das ist eindeutig die Syntax einer Funktions-Deklaration, und nicht die einer Objekt-Erzeugung. Hier wird eine freie Funktion "a2" deklariert, die keinen Parameter erwartet und ein "A"-Objekt per Wert zurückgibt.

Die beiden anderen Zeilen mit "a1" und "a3" sind dagegen problemlos:

class A
{
public:
   A();
   A(int);
};

int main()
{
   int var = 2;

   A a1(1);        // Objekt-Erzeugung, da "1" ein Wert ist
   A a2(var);      // Objekt-Erzeugung, da "var" ein Wert ist

   A f1(int);      // Funktions-Deklaration, da "int" ein Typ ist
   A f2(A);        // Funktions-Deklaration, da "A" ein Typ ist
}

1.2 Das Problem betrifft nicht nur lokale Objekte

Das Problem ist nicht nur auf lokale Objekte beschränkt, sondern betrifft auch globale oder statische Objekte. Ein Beispiel:

#include <string>

std::string s1;           // Globales Objekt vom Typ "std::string"
std::string s2();         // Funktions-Deklaration "s2"

static std::string s3;    // Statisches (quelltextlokales) Objekt vom Typ "std::string"
static std::string s4();  // Funktions-Deklaration "s4" einer quelltextlokalen Funktion

Selbst in Klassen kann uns dieses Problem begegnen:

class A
{
private:
   int n1;       // Ein normales "int" Attribute der Klasse "A"
   int n2();     // Deklaration der Element-Funktion "n2" ohne Parameter und mit Int-Rückgabe
};

Ich weiß natürlich, dass man Attribut-Initialisierungen erst seit C++11 direkt in der Klassen-Definition vornehmen kann - von daher wirkt das Beispiel mit "n2" für viele vielleicht etwas konstruiert. Aber lassen Sie sich gesagt sein: Anfänger machen sowas intuitiv - und nicht erst seit C++11 - und C++11 Nutzer ja nun auch. Und auch hier erhalten wir keinen Compiler-Fehler (warum auch), und bemerken daher den Fehler erstmal gar nicht. Erst die Nutzung von "n2" führt dann zu dem Alptraum von frustrierender C++ Syntax Fehlersuche.

1.3 Compiler-Meldungen

Da die "semantisch fehlerhafte Anweisung" syntaktisch okay ist, bekommt man an der Stelle der Funktions-Deklaration keine Fehler-Meldung vom Compiler. Die bekommt man erst, wenn man das vermeintliche Objekt, das ja in Wirklichkeit eine Funktion ist, nutzen will.

class A
{
public:
   A();
   void f();
};

int main()
{
   A a();      // Hier keine Meldung, warum auch?
   a.f();      // Compiler-Fehler erst bei der Nutzung des vermeintlichen Objekts "a"
}

Leider sind die Fehler-Meldungen für einen Anfänger zum Teil nicht wirklich hilfreich, auch wenn sie aus Sicht des Compiler vollkommen korrekt sind. Hier ein paar Beispiele für konkrete Fehler-Meldungen: Manche Anfänger tendieren nach ihrer ersten (schmerzhaften) Begegnung mit dieser Syntax dahin, dass ein Compiler solche Funktions-Deklarationen mit Warnungen anmeckern sollte. So verständlich diese Reaktion ist - sie ist leider nicht hilfreich, denn Funktions-Deklarationen sind vollkommen normal. Bedenken Sie: jede Funktion, die Sie nutzen wollen bzw. prinzipiell nutzen könnten, muss vorher deklariert werden. Also würde jetzt jede Deklaration einer Funktion mit leerer Parameterliste eine Warnung auslösen - manche Header also tausende von Warnungen hervorrufen. Wollen Sie das wirklich?

Eine mögliche sinnvolle Regel könnte hier sein, daß der Compiler warnt, wenn die Funktions-Deklaration in einem lokalen Scope vorgenommen wurde, und die Funktion nicht genutzt wird. Wir werden später sehen, dass manche Compiler das in manchen Fällen auch so machen.

Diese Regel hilft aber nur weiter, wenn die Funktion nicht genutzt wird. In unserem Fall ist dies aber nicht so, da wir die Funktion ja nutzen, nur eben fehlerhaft als Objekt. Hier helfen nur bessere Fehler-Meldungen für die falsche Funktions-Nutzung weiter. Z.B. Clang geht hier mit der Angabe des korrekten Typ von "a" in die richtige Richtung - wirklich gut ist die Fehler-Meldung aber trotzdem nicht.

2. Aber es gibt noch schlimmere Fallen

Es gibt noch schlimmere Dinge in der C++ Syntax. Sie glauben mir nicht - passen Sie auf.

Was ist "xyz" in der folgenden Anweisung?

long xyz(int());

Nun, ist doch klar: Leider falsch!

In Wirklichkeit ist das die Deklaration einer freien Funktion "xyz", die einen Funktions-Zeiger auf eine Funktion ohne Parameter mit Int-Rückgabe erwartet, und einen "long" zurückgibt. Falls Sie das nicht glauben - folgendes Beispiel zeigt dass es wirklich so ist. Ohne die Deklaration der Funktion "xyz" in der Zeile (*) wäre sie in der nachfolgenden Zeile (**) nicht aufrufbar.

#include <iostream>
using namespace std;

int f()
{
   cout << "Funktion f" << endl;
   return 0;
}

int main()
{
   cout << "Deklaration und Aufruf der Funktion xyz" << endl;
   long xyz(int());          // (*)
   xyz(f);                   // (**)
}

long xyz(int(*pf)())
{
   cout << "Funktion xyz" << endl;
   return pf();
}


Ausgabe:

Deklaration und Aufruf der Funktion xyz
Funktion xyz
Funktion f

Falls Sie jetzt mit dem Einwand kommen, dass Sie niemals Null-Integer Werte mit "int()" erzeugen, sondern schon den Wert "0" nehmen - wie sieht es denn z.B. bei Klassen aus, die überladene Konstruktoren haben - und man einen ganz speziellen möchte? Oder bei z.B. Vektoren oder anderen nicht so trivialen Typen, die keine Literal-Darstellung haben?

#include <string>
#include <vector>

class A
{
public:
   A(const char*);               // Const-Char-Zeiger-Konstruktor
   A(const std::string&);        // String-Konstruktor
};

class B
{
public:
   B(const std::vector<int>&);   // Vektor<Int>-Konstruktor
};

int main()
{
   A a(std::string());           // Ich will explizit den String-Konstruktor haben
   B b(std::vector<int>());      // Fuer Vektoren gibt es keinerlei Literale
}

Nein, "a" ist kein lokales Objekt der Klasse "A" - erzeugt mit einem leeren String als Argument des String-Konstruktors von "A". Auch hier ist das eine Funktions-Deklaration - die Deklaration von "a" als Funktion, die einen "Funktions-Zeiger auf eine Funktion mit "Std-String" Parameter und ohne Rückgabe" als Argument erwartet und "A" als Rückgabe-Typ hat.

Und das gleiche für "b" - auch eine Funktions-Deklaration.

2.1 Wie kommt denn das?

Um das zu verstehen, fangen wir erstmal klein an. Definieren wir doch einfach mal einen Funktions-Zeiger "pf", der auf eine Funktion zeigt, die einen "int" zurückgibt und keinen Parameter erwartet. Bei den meisten Programmierern sieht diese Deklaration so aus:

int(*pf)();

Das dies ein solch einfacher Funktions-Zeiger ist, können wir mit folgendem Beispiel leicht verifizieren:

#include <iostream>
using namespace std;

int f()
{
   cout << "Funktion f" << endl;
   return 0;
}

int main()
{
   cout << "Rufe Funktion f ueber Funktions-Zeiger pf auf" << endl;
   int(*pf)() = f;
   pf();
}


Ausgabe:

Rufe Funktion f ueber Funktions-Zeiger pf auf
Funktion f

Jetzt wird die Sache komplizierter, und wir deklarieren und definieren eine Void-Funktion "g", die einen solchen Funktions-Zeiger als Parameter erwartet.

void g(int (*pf)());                // Deklaration von "g"

void g(int (*pf)())                 // Definition von "g"
{
   cout << "Funktion g" << endl;
   pf();
}

Vielen Programmierern ist nun leider nicht klar, dass man einen Funktions-Zeiger in einem Typ auch ohne Zeiger-Syntax definieren kann - d.h. die Deklaration könnte auch ohne "*" auskommen. (Dies ist übrigens schon in C so definiert, wie weiter unten erklärt wird).

void g(int (*pf)());               // Original-Deklaration
void g(int (pf)());                // Die gleiche Deklaration nur ohne Zeiger-Syntax

Und da Klammern um Parameter-Namen überflüssig (aber erlaubt) sind, kann man auch diese weglassen.

void g(int (*pf)());               // Original-Deklaration
void g(int (pf)());                // Die gleiche Deklaration nur ohne Zeiger-Syntax
void g(int pf());                  // Klammern um Parameter-Namen sind optional

Was jetzt folgt, sollte Ihnen klar sein - man kann Parameter-Namen in Deklarationen ja einfach weglassen.

void g(int (*pf)());               // Original-Deklaration
void g(int (pf)());                // Die gleiche Deklaration nur ohne Zeiger-Syntax
void g(int pf());                  // Klammern um Parameter-Namen sind optional
void g(int());                     // Und Parameter-Namen sind auch optional

Das das wirklich noch unsere alten Funktions-Deklaration ist, kann man z.B. auch mit RTTI feststellen - selbst wenn manche Compiler eher kryptische Typ-Namen erzeugen.

#include <iostream>
#include <typeinfo>
using namespace std;

int main()
{
   void g1(int (*pf)());
   void g2(int (pf)());
   void g3(int pf());
   void g4(int());

   cout << typeid(g1).name() << endl;
   cout << typeid(g2).name() << endl;
   cout << typeid(g3).name() << endl;
   cout << typeid(g4).name() << endl;
}


Ausgabe: (hier mit dem Microsoft Visual Studio 2012)

void __cdecl(int (__cdecl*)(void))
void __cdecl(int (__cdecl*)(void))
void __cdecl(int (__cdecl*)(void))
void __cdecl(int (__cdecl*)(void))

Mit dem "void" und dem "int()" finden sich in dieser Funktions-Deklaration zwei Elemente, die eher untypisch für Objekt-Erzeugungen sind. Aber mit etwas anderen Typen sieht das ganze eigentlich ganz normal aus, nicht wahr?

#include <string>

class A
{
public:
   A(const std::string&);
   void fct();
};

int main()
{
   A a(std::string());        // Keine Objekt-Erzeugung, sondern Funktions-Deklaration
   a.fct();                   // Compiler-Fehler
}

2.2 Lösungen

Die wohl einfachste Lösung ist, das Konstruktor-Argument als temporäre Variable zu erzeugen und diese dann zu übergeben.

std::string temp;
A a(temp);               // Diesmal ist "a" ganz eindeutig ein Objekt, da "temp" ein Wert ist

Alternativ kann man das gesamte Argument auch extra klammern.

A a((std::string()));    // Man beachte die extra Klammerung des Arguments

Die Klammerung eines Parameters in einer Typ-Deklaration ist nicht erlaubt. Dagegen darf man Ausdrücke beliebig oft klammern. Damit kann die Zeile keine Typ-Deklaration mehr sein, kann aber immer noch als gültige Konstruktor-Aufruf interpretiert werden. Und das passiert dann auch.

Mit den extra Klammern funktioniert alles:

#include <iostream>
#include <string>
using namespace std;

class A
{
public:
   A(const std::string&) {}
   void fct() { std::cout << "A::fct()" << std::endl; }
};

int main()
{
   A a((std::string()));         // Mit extra Klammern kein Problem - "a" ist Objekt
   a.fct();                      // Normaler Aufruf der Element-Funktion "fct"
}


Ausgabe:

A::fct()

2.3 Ältere Compiler

Es soll ältere Compiler geben, die Probleme mit den extra Klammern haben. Für die sollte der folgende Quelltext immer noch nicht gültig sein. Leider kenne ich keinen solchen Compiler. Wenn Sie einen kennen, würde ich mich über eine Info freuen.

2.4 Historie

Letzlich ist dies übrigens ein Effekt, der auf C zurückgeht. Schon in C wurde definiert, dass leere Klammern in einer Typ-Deklaration als Funktion ohne Parameter interpretiert werden[3]. Nur wird dieser Umstand in fast allen Büchern immer verschwiegen. Selbst das extrem empfehlenswerte Buch "Expert C Programmierung" von Peter van der Linden[4] erklärt zwar wunderschön wie man Typ-Deklarationen in C liest - läßt diese Besonderheit aber leider aus.

3. Und es geht noch schlimmer

Sie haben wirklich einen Augenblick geglaubt, dass es das war? Tut mir leid, aber es geht noch schlimmer. Stellen Sie sich vor, Sie haben eine Int-Variable "n" und wollen mit dieser einen Char-Variable "c" anlegen.

Da Sie natürlich wissen, daß ein "int" größer ist als ein "char", treffen Sie entsprechende Vorsichtsmaßnahmen:

int n('A');
char c(char(n));           // (*)

Und, erwarten Sie irgendwelche Probleme?

Wahrscheinlich jetzt schon, denn Sie sind ja nicht dumm und haben dazu gelernt. Und Sie wissen dass dieser Artikel Probleme beschreibt. Aber wenn Sie die Zeile (*) in normalem Code gesehen hätten - hätten Sie dort Probleme erwartet? Wahrscheinlich "Nein".

Leider gibt es ein Problem. Das Symbol "c" ist nämlich auch hier keine Variable, sondern wieder eine Funktions-Deklaration - diesmal für eine Funktion, die einen "char" erwartet und einen "char" zurückgibt. Selbst wenn Sie es nicht glauben - der Standard und die Compiler sind da eindeutig.

#include <iostream>
#include <typeinfo>
using namespace std;

int main()
{
   int n('A');                          // (*)
   char c(char(n));
   cout << typeid(c).name() << endl;
}


Ausgabe: (hier mit dem Microsoft Visual Studio 2012)

char __cdecl(char)

Für den Compiler ist dies eine Funktions-Deklaration, da er "n" als Parameter des Typs "char" interpretiert. Die Klammern um "n" ignoriert er einfach, da Klammern um Parameter erlaubt, aber überflüssig, sind. Warum er das so macht, lernen wir gleich in Kapitel 4.

Hinweis - manche Compiler warnen die ungenutzte lokale Variable "n" in Zeile (*) an (siehe z.B. GCC 4.7.2 im nächsten Kapitel 3.1), und liefern damit einen Hinweis auf das Problem. In der Praxis ist meine Erfahrung aber, dass die Nutzer, die diese Warnung bekommen, den Compiler für fehlerhaft halten und nicht Ihren Code.

Mögliche Lösungen sind hier, wenn sie denn zum eingesetzten Typen passen:

#include <iostream>
#include <typeinfo>
using namespace std;

int main()
{
   int n('A');

   char c1(static_cast<char>(n));       // Static-Cast statt funktionalem Cast
   cout << typeid(c1).name() << endl;

   char c2 = char(n);                   // Operator = (ist aber nicht immer gut)
   cout << typeid(c2).name() << endl;

   char c3((char(n)));                  // Extra Klammern
   cout << typeid(c3).name() << endl;
}


Ausgabe: (hier mit dem Microsoft Visual Studio 2012)

char
char
char

3.1 Compiler Warnungen

Einige Compiler warnen genau dieses Problem an, zumindest wenn einige Bedingungen erfüllt sind: Das probieren wir mal aus. Darum hier ein ähnliches Beispiel - aber diesmal statt mit elementaren Daten-Typen mit den Klassen "A" und "B":

#include <iostream>
#include <typeinfo>
using namespace std;

class A
{
public:
   explicit A(int) {}
};

class B
{
public:
   B(const A&) {}
};

int main()
{
   int n = 1;
   B b(A(n));                          // Fkts-Deklaration von "b"
   cout << typeid(b).name() << endl;
}


Ausgabe: (hier mit dem Microsoft Visual Studio 2012)

class B __cdecl(class A)

Welche Compiler warnen - und wenn ja wie? Wie Sie sehen, liefern manche Compiler wirklich hilfreiche Warnungen - man darf sie nur nicht ignorieren und muss sie verstehen. Und das Verstehen ist leider meist das eigentliche Problem. Aber nicht mehr nach dem Lesen dieses Artikels ;-)

4. Typ-Deklarationen gehen vor

Alle drei Probleme haben letztlich den gemeinsamen Grund, dass die C++ Syntax hier nicht eindeutig ist. Alle drei Varianten könnten prinzipiell sowohl als Objekt-Definitionen als auch als Funktions-Deklarationen geparst werden.

// Alles keine lokalen Objekte, sondern Funktions-Deklarationen

int n1();         // -> int (*n1)()
int n2(int());    // -> int (*n2)(int(*)())
int n3(int(x));   // -> int (*n3)(int)

In diesem Fall schlägt eine ganz grundlegende Regel von C++ zu: Wenn etwas als Typ-Deklaration verstanden werden kann, dann ist es eine Typ-Deklaration[5]. Diese Interpretation hat immer Vorrang vor allen anderen Interpretations-Möglichkeiten.

4.1 Ein reales Beispiel

Im sehr empfehlenswerten Buch "Effective STL"[6] beschreibt Scott Meyers in Item 6 ein Beispiel, in dem die letzten beiden Probleme gemeinsam auftreten. Dieses Beispiel ist deshalb besonders ärgerlich, da es kein konstruiertes Beispiel ist, sondern typischen realen Code darstellt.

// Beispiel aus "Effective STL" von Scott Meyers - Item 6

list<int> data(istream_iterator<int>(file), istream_iterator<int>());

Hier sollen aus einer Datei (ifstream "file") mit den beiden temporären Istream-Iteratoren ("istream_iterator<int>(file)" und "istream_iterator<int>()") Integer ausgelesen und in die Liste "data" übertragen werden. Eigentlich eine sehr guter Lösung - die Nutzung von Stream-Iteratoren für die Datei und die Nutzung des entsprechenden List-Konstruktors.

Leider aber eben keine wirklich sehr gute Lösung, denn was hier nach einer Listen-Konstruktion aussieht, ist eben in Wirklichkeit eine Funktions-Deklaration:

5. C++11

Als vom 6.-14. Juni 2008 beim ISO C++ Standardisierungs-Treffen in Sophia Antipolis, France die Initialisierungs-Listen[7,8,9] als eine allgemeine Syntax für Objekt-Konstruktionen in den mittlerweile aktuellen Standard C++11 aufgenommen wurden, dachte ich: "Nun sind alle Probleme vorbei. Eine einheitliche Syntax für alle Arten von Objekten und Objekt-Konstruktionen - auch in Fällen wo in C++03 gar keine Initialsierungen möglich waren, wie z.B. bei Containern oder C-Arrays in Klassen. Und sie würde alle diese Probleme beseitigen - und eben noch mehr."

Ein großer Teil der Hoffnungen haben sich bewahrheitet. Viele Probleme sind mit Initialisierungs-Listen Vergangenheit - so auch unsere:

#include <iostream>
#include <typeinfo>
using namespace std;

class A
{
public:
   A() {}
   A(int) {}

   void fct() { cout << "A::fct()" << endl; }
};

int main()
{
  A a1;
   A a2{};                                         // Problem-Fall 1 - so kein Problem
   A a3{5};

   cout << typeid(a2).name() << endl;
   a2.fct();

   cout << endl;

   // ---

   long xyz{int()};                                // Problem-Fall 2 - so kein Problem

   cout << typeid(xyz).name() << endl;
   cout << "xyz: " << xyz << endl;

   cout << endl;

   // ---

   int n{'A'};
   char c{char(n)};                                // Problem-Fall 3 - so kein Problem

   cout << typeid(c).name() << endl;
   cout << "c: " << c << endl;
}


Ausgabe: (hier mit dem Microsoft Visual Studio Nov 2012 CTP)

class A
A::fct()

long
xyz: 0

char
c: A

Leider hat sich aber auch wieder mal herausgestellt, dass kein neues Sprach-Feature ganz ohne Probleme ist. Das trifft auch Initialisierungs-Listen zu. Ein schöner Artikel, der einige Probleme der Initialisierungs-Listen beschreibt, findet sich hier[10]. Und darum wird für C++14 schon über Erweiterungen der Initialisierungs-Listen[11] nachgedacht. Aber das ist ein anderes Thema und daher nichts für hier und heute.

6. Links & Literatur

  1. Comeau Online-Compiler
    • http://www.comeaucomputing.com/tryitout
    • Leider ist der Comeau Online-Compiler, wie die gesamte Comeau Web-Seite, nicht mehr online.
    • Das ich die Ergebnisse hier trotzdem zur Verfügung habe liegt daran, dass ich diese Tests schon 2008 mit dem Comeau Online-Compiler durchgeführt habe.

  2. Nachzulesen z.B. im ISO C Standard von 1999
    • ISO/IEC 9899:1999
    • Kapitel 6.7.6 ("Type names"), Fußnote 126

  3. Buch "Expert C Programmierung" von Peter van der Linden
    • Heise Verlag 1995, 1. Auflage
    • ISBN 978-3-88229-047-9

  4. ISO C++ Standard von 2003
    • ISO/IEC 14882:2003
    • Kapitel 8.2 ("Ambiguity resolution")

  5. Buch "Effective STL" von Scott Meyers
    • Addison-Wesley 2001, 1. Auflage
    • ISBN 978-0-201-74962-5

  6. N2215, Initializer lists (Rev. 3)

  7. N2672, Initializer List proposed wording

  8. N2679, Initializer Lists for Standard Containers (Rev. 1)

  9. Probleme in den Initialisierungs-Listen

  10. Proposal für Erweiterungen an den Initialisierungs-Listen in C++14 oder C++17

  11. Ich bin natürlich nicht der Erste, der diese Probleme beschrieben hat. Einen vergleichbaren Artikel für Problem 2 hat z.B. Danny Kalev schon 2009 geschrieben.

7. Danksagung

Bedanken möchte ich mich bei folgenden Personen (in alphabetischer Reihenfolge), die diesen Artikel gegengelesen und mit vielen kleinen und großen Hinweisen dafür gesorgt haben, dass er besser ist als am Anfang: Falls der Artikel noch Fehler enthält, so sind diese allein mir zuzuschreiben.

8. Versions-Historie

Die Versions-Historie dieses Artikels:
Schlagwörter: