Je suis en train de discuter avec un collègue de la possibilité de lever des exceptions de la part des constructeurs.
Est-il acceptable de supprimer les exceptions des constructeurs, du point de vue de la conception?
Disons que je suis en train d'envelopper un mutex POSIX dans une classe, cela ressemblerait à ceci:
class Mutex {
public:
Mutex() {
if (pthread_mutex_init(&mutex_, 0) != 0) {
throw MutexInitException();
}
}
~Mutex() {
pthread_mutex_destroy(&mutex_);
}
void lock() {
if (pthread_mutex_lock(&mutex_) != 0) {
throw MutexLockException();
}
}
void unlock() {
if (pthread_mutex_unlock(&mutex_) != 0) {
throw MutexUnlockException();
}
}
private:
pthread_mutex_t mutex_;
};
Ma question est la suivante: est-ce la façon habituelle de le faire? Parce que si l'appel pthread mutex_init
échoue, l'objet mutex est inutilisable. Par conséquent, le lancement d'une exception garantit que le mutex ne sera pas créé.
Devrais-je plutôt créer une fonction membre init pour la classe Mutex et appeler pthread mutex_init
dans lequel renverrait un bool basé sur le retour de pthread mutex_init
? De cette façon, je n'ai pas à utiliser d'exceptions pour un objet de niveau aussi bas.
Oui, le recours à une exception à partir du constructeur défaillant est la méthode standard. Lisez ceci FAQ à propos de Traitement d'un constructeur qui échoue pour plus d'informations. Avoir une méthode init () fonctionnera également, mais toute personne qui crée l'objet de mutex doit se rappeler que init () doit être appelée. Je pense que cela va à l’encontre du principe RAII .
Si vous lancez une exception depuis un constructeur, n'oubliez pas que vous devez utiliser la syntaxe try/catch si vous devez intercepter cette exception dans une liste d'initialisation de constructeur.
par exemple.
func::func() : foo()
{
try {...}
catch (...) // will NOT catch exceptions thrown from foo constructor
{ ... }
}
vs.
func::func()
try : foo() {...}
catch (...) // will catch exceptions thrown from foo constructor
{ ... }
Lancer une exception est le meilleur moyen de gérer les échecs de constructeur. Vous devriez particulièrement éviter de construire à moitié un objet et de vous fier ensuite aux utilisateurs de votre classe pour détecter les échecs de construction en testant des variables de type indicateur.
Sur un point connexe, le fait que vous disposiez de plusieurs types d’exception différents pour traiter les erreurs de mutex me préoccupe légèrement. L'héritage est un excellent outil, mais il peut être surexploité. Dans ce cas, je préférerais probablement une seule exception MutexError, contenant éventuellement un message d'erreur informatif.
#include <iostream>
class bar
{
public:
bar()
{
std::cout << "bar() called" << std::endl;
}
~bar()
{
std::cout << "~bar() called" << std::endl;
}
};
class foo
{
public:
foo()
: b(new bar())
{
std::cout << "foo() called" << std::endl;
throw "throw something";
}
~foo()
{
delete b;
std::cout << "~foo() called" << std::endl;
}
private:
bar *b;
};
int main(void)
{
try {
std::cout << "heap: new foo" << std::endl;
foo *f = new foo();
} catch (const char *e) {
std::cout << "heap exception: " << e << std::endl;
}
try {
std::cout << "stack: foo" << std::endl;
foo f;
} catch (const char *e) {
std::cout << "stack exception: " << e << std::endl;
}
return 0;
}
le résultat:
heap: new foo
bar() called
foo() called
heap exception: throw something
stack: foo
bar() called
foo() called
stack exception: throw something
les destructeurs ne sont pas appelés. Ainsi, si une exception doit être générée dans un constructeur, il vous reste beaucoup de choses à faire (par exemple, nettoyer?).
Il est possible de lancer depuis votre constructeur, mais vous devez vous assurer que votre objet est construit après main et avant qu'il se termine:
class A
{
public:
A () {
throw int ();
}
};
A a; // Implementation defined behaviour if exception is thrown (15.3/13)
int main ()
{
try
{
// Exception for 'a' not caught here.
}
catch (int)
{
}
}
Mis à part le fait que vous n'avez pas besoin de jeter du constructeur dans votre cas spécifique car pthread_mutex_lock
renvoie en fait un EINVAL si votre mutex n’a pas été initialisé et vous pouvez lancer après l’appel de lock
comme dans std::mutex
:
void
lock()
{
int __e = __gthread_mutex_lock(&_M_mutex);
// EINVAL, EAGAIN, EBUSY, EINVAL, EDEADLK(may)
if (__e)
__throw_system_error(__e);
}
alors en général jeter des constructeurs est ok pour acquisition erreurs lors de la construction, et en conformité avec RAII (Resource- acquisition-est-initialisation) paradigme de programmation.
Vérifiez ceci exemple sur RAII
void write_to_file (const std::string & message) {
// mutex to protect file access (shared across threads)
static std::mutex mutex;
// lock mutex before accessing file
std::lock_guard<std::mutex> lock(mutex);
// try to open file
std::ofstream file("example.txt");
if (!file.is_open())
throw std::runtime_error("unable to open file");
// write message to file
file << message << std::endl;
// file will be closed 1st when leaving scope (regardless of exception)
// mutex will be unlocked 2nd (from lock destructor) when leaving
// scope (regardless of exception)
}
Concentrez-vous sur ces déclarations:
static std::mutex mutex
std::lock_guard<std::mutex> lock(mutex);
std::ofstream file("example.txt");
La première déclaration est RAII et noexcept
. Dans (2) il est clair que RAII est appliqué sur lock_guard
et il peut en fait throw
, alors que dans (3) ofstream
ne semble pas être RAII, car l'état de l'objet doit être vérifié en appelant is_open()
qui vérifie l'indicateur failbit
.
À première vue, il semble qu'il soit indécis sur ce que la méthode standard et dans le premier cas std::mutex
ne lance pas l'initialisation *, contrairement à la mise en œuvre de l'OP *. Dans le second cas, il lancera tout ce qui est projeté de std::mutex::lock
, et dans le troisième, il n'y a pas de lancer du tout.
Remarquez les différences:
(1) Peut être déclaré statique et sera déclaré en tant que variable membre (2) Ne sera jamais censé être déclaré en tant que variable membre (3) Est censé être déclaré en tant que variable membre et la ressource sous-jacente peut pas toujours être disponible.
Toutes ces formes sont RAII; pour résoudre ce problème, il faut analyser RAII.
Cela ne vous oblige pas à tout initialiser et à tout connecter lors de la construction. Par exemple, lorsque vous créez un objet client réseau, vous ne le connectez pas au serveur lors de sa création, car il s’agit d’une opération lente avec échecs. Vous voudriez plutôt écrire une fonction connect
pour le faire. D'autre part, vous pouvez créer les tampons ou simplement définir son état.
Par conséquent, votre problème se résume à définir votre état initial. Si dans votre cas votre état initial est le mutex doit être initialisé alors vous devriez jeter depuis le constructeur. En revanche, il est tout à fait correct de ne pas initialiser ensuite (comme dans std::mutex
), et de définir votre état invariant comme le mutex est créé. En tout état de cause, l'invariant n'est pas nécessairement compromis par l'état de son objet membre, car l'objet mutex_
subit une mutation entre locked
et unlocked
via les Mutex
méthodes publiques Mutex::lock()
et Mutex::unlock()
. .
class Mutex {
private:
int e;
pthread_mutex_t mutex_;
public:
Mutex(): e(0) {
e = pthread_mutex_init(&mutex_);
}
void lock() {
e = pthread_mutex_lock(&mutex_);
if( e == EINVAL )
{
throw MutexInitException();
}
else (e ) {
throw MutexLockException();
}
}
// ... the rest of your class
};
Si votre projet repose généralement sur des exceptions pour distinguer les données incorrectes des données correctes, le lancement d'une exception depuis le constructeur est une meilleure solution que de ne pas le faire. Si aucune exception n'est levée, l'objet est initialisé dans un état zombie. Cet objet doit exposer un drapeau indiquant si l'objet est correct ou non. Quelque chose comme ça:
class Scaler
{
public:
Scaler(double factor)
{
if (factor == 0)
{
_state = 0;
}
else
{
_state = 1;
_factor = factor;
}
}
double ScaleMe(double value)
{
if (!_state)
throw "Invalid object state.";
return value / _factor;
}
int IsValid()
{
return _status;
}
private:
double _factor;
int _state;
}
Le problème avec cette approche est du côté de l'appelant. Chaque utilisateur de la classe devrait faire un if avant d’utiliser réellement l’objet. Ceci est un appel à des bogues - rien de plus simple que d’oublier de tester une condition avant de continuer.
En cas de lancement d'une exception du constructeur, l'entité qui construit l'objet est supposée s'occuper des problèmes immédiatement. Les consommateurs d’objets en aval sont libres d’assumer que cet objet est opérationnel à 100% du simple fait de l’avoir obtenu.
Cette discussion peut se poursuivre dans plusieurs directions.
Par exemple, utiliser des exceptions pour valider est une mauvaise pratique. Une façon de le faire est d'utiliser un motif Try en conjonction avec la classe factory. Si vous utilisez déjà des usines, écrivez deux méthodes:
class ScalerFactory
{
public:
Scaler CreateScaler(double factor) { ... }
int TryCreateScaler(double factor, Scaler **scaler) { ... };
}
Avec cette solution, vous pouvez obtenir l'indicateur d'état sur place, en tant que valeur de retour de la méthode d'usine, sans jamais entrer le constructeur avec des données incorrectes.
Deuxièmement, si vous couvrez le code avec des tests automatisés. Dans ce cas, tout morceau de code utilisant un objet ne générant pas d'exceptions devrait être couvert par un test supplémentaire - qu'il agisse correctement lorsque la méthode IsValid () renvoie false. Cela explique très bien que l’initialisation d’objets dans l’état de zombie est une mauvaise idée.
La seule fois où vous ne lirez PAS d'exceptions aux constructeurs, c'est si votre projet a pour règle de ne pas utiliser les exceptions (par exemple, Google n'aime pas les exceptions). Dans ce cas, vous ne voudriez pas utiliser d'exceptions dans votre constructeur, pas plus que partout ailleurs, et vous auriez plutôt besoin d'une méthode init.
En ajoutant à toutes les réponses ici, j’ai pensé mentionner, une raison/scénario très spécifique où vous voudrez peut-être préférer lever l’exception de la méthode Init
de la classe et non du Ctor (qui est bien sûr la méthode préférée). approche plus commune).
Je mentionnerai à l'avance que cet exemple (scénario) suppose que vous n'utilisez pas de "pointeurs intelligents" (c'est-à-dire. std::unique_ptr
) pour les membres de données du pointeur de votre classe.
Donc au point: Dans le cas où vous souhaiteriez que le Dtor de votre classe "agisse" lorsque vous l'invoquerez après (pour ce cas) vous attraperez l'exception que votre méthode Init()
jeté - vous NE DEVEZ PAS jeter l'exception du Ctor, car une invocation Dtor pour Ctor n'est PAS invoquée sur des objets "à moitié cuits".
Voir l'exemple ci-dessous pour illustrer mon propos:
#include <iostream>
using namespace std;
class A
{
public:
A(int a)
: m_a(a)
{
cout << "A::A - setting m_a to:" << m_a << endl;
}
~A()
{
cout << "A::~A" << endl;
}
int m_a;
};
class B
{
public:
B(int b)
: m_b(b)
{
cout << "B::B - setting m_b to:" << m_b << endl;
}
~B()
{
cout << "B::~B" << endl;
}
int m_b;
};
class C
{
public:
C(int a, int b, const string& str)
: m_a(nullptr)
, m_b(nullptr)
, m_str(str)
{
m_a = new A(a);
cout << "C::C - setting m_a to a newly A object created on the heap (address):" << m_a << endl;
if (b == 0)
{
throw exception("sample exception to simulate situation where m_b was not fully initialized in class C ctor");
}
m_b = new B(b);
cout << "C::C - setting m_b to a newly B object created on the heap (address):" << m_b << endl;
}
~C()
{
delete m_a;
delete m_b;
cout << "C::~C" << endl;
}
A* m_a;
B* m_b;
string m_str;
};
class D
{
public:
D()
: m_a(nullptr)
, m_b(nullptr)
{
cout << "D::D" << endl;
}
void InitD(int a, int b)
{
cout << "D::InitD" << endl;
m_a = new A(a);
throw exception("sample exception to simulate situation where m_b was not fully initialized in class D Init() method");
m_b = new B(b);
}
~D()
{
delete m_a;
delete m_b;
cout << "D::~D" << endl;
}
A* m_a;
B* m_b;
};
void item10Usage()
{
cout << "item10Usage - start" << endl;
// 1) invoke a normal creation of a C object - on the stack
// Due to the fact that C's ctor throws an exception - its dtor
// won't be invoked when we leave this scope
{
try
{
C c(1, 0, "str1");
}
catch (const exception& e)
{
cout << "item10Usage - caught an exception when trying to create a C object on the stack:" << e.what() << endl;
}
}
// 2) same as in 1) for a heap based C object - the explicit call to
// C's dtor (delete pc) won't have any effect
C* pc = 0;
try
{
pc = new C(1, 0, "str2");
}
catch (const exception& e)
{
cout << "item10Usage - caught an exception while trying to create a new C object on the heap:" << e.what() << endl;
delete pc; // 2a)
}
// 3) Here, on the other hand, the call to delete pd will indeed
// invoke D's dtor
D* pd = new D();
try
{
pd->InitD(1,0);
}
catch (const exception& e)
{
cout << "item10Usage - caught an exception while trying to init a D object:" << e.what() << endl;
delete pd;
}
cout << "\n \n item10Usage - end" << endl;
}
int main(int argc, char** argv)
{
cout << "main - start" << endl;
item10Usage();
cout << "\n \n main - end" << endl;
return 0;
}
Je mentionnerai à nouveau que ce n’est pas l’approche recommandée, je voulais simplement partager un point de vue supplémentaire.
En outre, comme vous l'avez peut-être vu dans le code, le résultat est basé sur le point 10 du fantastique "Plus efficace C++" de Scott Meyers (1ère édition).
J'espère que ça aide.
À votre santé,
Gars.