245 votes

Quand faut-il qu'un constructeur lève une exception ?

Quand un constructeur doit-il lever une exception ? (Ou dans le cas de l'Objective C : quand est-il bon pour un init'er de retourner nil ?)

Il me semble qu'un constructeur devrait échouer -- et donc refuser de créer un objet -- si l'objet n'est pas complet. C'est-à-dire que le constructeur devrait avoir un contrat avec son appelant pour fournir un objet fonctionnel sur lequel les méthodes peuvent être appelées de manière significative ? Est-ce raisonnable ?

335voto

Sebastian Redl Points 18816

Le rôle du constructeur est d'amener l'objet dans un état utilisable. Il y a essentiellement deux écoles de pensée à ce sujet.

Un groupe est favorable à une construction en deux étapes. Le constructeur ne fait qu'amener l'objet dans un état de sommeil dans lequel il refuse d'effectuer tout travail. Il y a une fonction supplémentaire qui fait l'initialisation réelle.

Je n'ai jamais compris le raisonnement qui sous-tend cette approche. Je fais partie du groupe qui soutient la construction en une étape, où l'objet est entièrement initialisé et utilisable après la construction.

Les constructeurs à une étape doivent lancer s'ils ne parviennent pas à initialiser complètement l'objet. Si l'objet ne peut pas être initialisé, il ne doit pas être autorisé à exister, donc le constructeur doit lancer.

10 votes

Les classes avec des constructeurs à une étape ne peuvent pas facilement être utilisées dans les tests unitaires en les sous-classant.

44 votes

La construction en deux étapes est destinée aux environnements où les exceptions ne fonctionnent pas correctement ou ne sont pas mises en œuvre. MFC utilise la construction en deux étapes parce que lorsqu'il a été écrit à l'origine, Visual C++ n'avait pas d'exceptions C++. Windows CE n'a pas eu d'exceptions C++ avant la version 4.0, et MFC 8.0.

54 votes

@EricSchaefer : Pour les tests unitaires, je pense qu'il est préférable de simuler les dépendances, plutôt que d'utiliser les sous-classes.

73voto

Jacob Krall Points 10327

Eric Lippert dit il existe 4 types d'exceptions.

  • Les exceptions fatales ne sont pas de votre faute, vous ne pouvez pas les empêcher et vous ne pouvez pas les éliminer de manière raisonnable.
  • Les exceptions stupides sont de votre propre faute, vous auriez pu les éviter et ce sont donc des bogues dans votre code.
  • Les exceptions contrariantes sont le résultat de décisions de conception malheureuses. Elles sont lancées dans des circonstances tout à fait exceptionnelles, et doivent donc être capturées et traitées en permanence.
  • Enfin, les exceptions exogènes ressemblent un peu aux exceptions vexantes, sauf qu'elles ne sont pas le résultat de choix de conception malheureux. Elles sont plutôt le résultat de réalités externes désordonnées qui empiètent sur votre belle et nette logique de programme.

Votre constructeur ne devrait jamais lever une exception fatale par lui-même, mais le code qu'il exécute peut provoquer une exception fatale. Quelque chose comme "out of memory" n'est pas quelque chose que vous pouvez contrôler, mais si cela se produit dans un constructeur, eh, ça arrive.

Les exceptions stupides ne devraient jamais apparaître dans votre code, elles sont donc à proscrire.

Les exceptions qui dérangent (l'exemple est Int32.Parse() ) ne devraient pas être lancés par les constructeurs, parce qu'ils n'ont pas de circonstances non exceptionnelles.

Enfin, les exceptions exogènes devraient être évitées, mais si vous faites quelque chose dans votre constructeur qui dépend de circonstances externes (comme le réseau ou le système de fichiers), il serait approprié de lancer une exception.

Lien de référence : https://blogs.msdn.microsoft.com/ericlippert/2008/09/10/vexing-exceptions/

8 votes

Alors où se situe Argument[Null]Exception dans ce schéma ? S'agit-il d'une exception stupide, qui ne devrait donc pas être levée ? Ou s'agit-il d'une exception fatale qui peut donc être levée ?

11 votes

Je réalise maintenant que je n'ai pas inclus un lien vers l'article original, blogs.msdn.com/b/ericlippert/archive/2008/09/10/ Des choses comme ArgumentException/ArgumentNullException sont boneheaded : juste de simples bugs dans le code appelant. Il dit : "Corrigez votre code de manière à ce qu'il ne déclenche jamais d'exception stupide - une exception de type 'index out of range' ne devrait jamais se produire dans un code de production".

16 votes

@alastairs : Vous devriez vraiment lancez ArgumentExceptions, car la seule alternative est de prétendre que les arguments sont valides alors qu'ils ne le sont pas. (Ce qui conduit à des NullReferenceExceptions, ou peut-être à des choses bien pires.) Mais comme le dit Jacob, vous devriez jamais les attraper.

36voto

Il y a généralement Il n'y a rien à gagner à séparer l'initialisation de l'objet de la construction. RAII est correct, un appel réussi au constructeur devrait soit résulter en un objet vivant complètement initialisé soit échouer, et TOUTES Les échecs à tout moment dans n'importe quel chemin de code devraient toujours lever une exception. Vous ne gagnez rien à utiliser une méthode init() séparée, sauf une complexité supplémentaire à un certain niveau. Le contrat du ctor devrait être le suivant : soit il renvoie un objet fonctionnel valide, soit il nettoie après lui-même et jette.

Considérez que si vous implémentez une méthode init séparée, vous toujours de l'appeler. Il aura toujours le potentiel de lancer des exceptions, elles doivent toujours être gérées et elles doivent pratiquement toujours être appelées immédiatement après le constructeur de toute façon, sauf que maintenant vous avez 4 états d'objets possibles au lieu de 2 (IE, construit, initialisé, non initialisé, et échoué par rapport à juste valide et inexistant).

Dans tous les cas, j'ai rencontré en 25 ans de développement OO des cas où il semble qu'une méthode d'initialisation séparée "résoudrait un problème" sont des défauts de conception. Si vous n'avez pas besoin d'un objet MAINTENANT, vous ne devriez pas le construire maintenant, et si vous en avez besoin maintenant, vous devez l'initialiser. Le principe KISS devrait toujours être suivi, ainsi que le simple concept selon lequel le comportement, l'état et l'API d'une interface devraient refléter ce que l'objet fait, et non comment il le fait. Le code client ne devrait même pas être conscient que l'objet a une sorte d'état interne qui nécessite une initialisation, donc le modèle init after viole ce principe.

1 votes

Un contre-exemple au "construire seulement quand c'est nécessaire". S'il est nécessaire dans le corps d'une boucle, le déclarer à cet endroit le détruira lorsque le champ d'application sortira, peut-être inutilement. Le fait de séparer la construction (dans une plus grande portée) et l'initialisation/réinitialisation (à l'intérieur de la boucle) permet de réutiliser les ressources pour les itérations suivantes, au lieu de les nettoyer complètement et de devoir les reconstruire plusieurs fois.

2 votes

S'il y a une logique significative à faire dans l'initialisation, et que vous voulez avoir une variété de c'tors qui peuvent réutiliser cette logique, alors avoir un init() séparé est correct d'une perspective DRY, qui fait partie de KISS. L'initialisation peut toujours être effectuée "en coulisses" au moment de la construction.

0 votes

Créer une méthode d'usine pour l'objet qui appelle le constructeur et ensuite la méthode init.

9voto

Christopher Harris Points 7887

Pour autant que je puisse dire, personne ne présente une solution assez évidente qui incarne le meilleur de la construction en une et en deux étapes.

note : Cette réponse suppose le langage C#, mais les principes peuvent être appliqués dans la plupart des langages.

Tout d'abord, les avantages des deux :

Une étape

La construction en une étape est avantageuse car elle empêche les objets d'exister dans un état invalide, ce qui évite toutes sortes de gestion d'état erronée et tous les bogues qui en découlent. Cependant, certains d'entre nous se sentent bizarres car nous ne voulons pas que nos constructeurs lèvent des exceptions, et c'est parfois ce que nous devons faire lorsque les arguments d'initialisation sont invalides.

public class Person
{
    public string Name { get; }
    public DateTime DateOfBirth { get; }

    public Person(string name, DateTime dateOfBirth)
    {
        if (string.IsNullOrWhitespace(name))
        {
            throw new ArgumentException(nameof(name));
        }

        if (dateOfBirth > DateTime.UtcNow) // side note: bad use of DateTime.UtcNow
        {
            throw new ArgumentOutOfRangeException(nameof(dateOfBirth));
        }

        this.Name = name;
        this.DateOfBirth = dateOfBirth;
    }
}

Méthode de validation en deux étapes

La construction en deux étapes est avantageuse car elle permet à notre validation d'être exécutée en dehors du constructeur, ce qui évite de devoir lancer des exceptions dans le constructeur. Cependant, cela nous laisse avec des instances "invalides", ce qui signifie qu'il y a un état que nous devons suivre et gérer pour l'instance, ou nous la jetons immédiatement après l'allocation du tas. Cela soulève la question : Pourquoi effectuons-nous une allocation au tas, et donc une collecte de mémoire, sur un objet que nous n'utilisons même pas ?

public class Person
{
    public string Name { get; }
    public DateTime DateOfBirth { get; }

    public Person(string name, DateTime dateOfBirth)
    {
        this.Name = name;
        this.DateOfBirth = dateOfBirth;
    }

    public void Validate()
    {
        if (string.IsNullOrWhitespace(Name))
        {
            throw new ArgumentException(nameof(Name));
        }

        if (DateOfBirth > DateTime.UtcNow) // side note: bad use of DateTime.UtcNow
        {
            throw new ArgumentOutOfRangeException(nameof(DateOfBirth));
        }
    }
}

Single-Stage via un constructeur privé

Alors, comment pouvons-nous garder les exceptions hors de nos constructeurs, et nous empêcher d'effectuer une allocation au tas sur des objets qui seront immédiatement jetés ? C'est assez simple : nous rendons le constructeur privé et créons des instances par le biais d'une méthode statique désignée pour effectuer une instanciation, et donc une allocation au tas, seulement après validation.

public class Person
{
    public string Name { get; }
    public DateTime DateOfBirth { get; }

    private Person(string name, DateTime dateOfBirth)
    {
        this.Name = name;
        this.DateOfBirth = dateOfBirth;
    }

    public static Person Create(
        string name,
        DateTime dateOfBirth)
    {
        if (string.IsNullOrWhitespace(Name))
        {
            throw new ArgumentException(nameof(name));
        }

        if (dateOfBirth > DateTime.UtcNow) // side note: bad use of DateTime.UtcNow
        {
            throw new ArgumentOutOfRangeException(nameof(DateOfBirth));
        }

        return new Person(name, dateOfBirth);
    }
}

Async Single-Stage via un constructeur privé

Outre les avantages susmentionnés en matière de validation et de prévention de l'allocation de tas, la méthodologie précédente nous offre un autre avantage intéressant : le support asynchrone. Cela s'avère très utile dans le cas d'une authentification en plusieurs étapes, par exemple lorsque vous devez récupérer un jeton de porteur avant d'utiliser votre API. De cette façon, vous ne vous retrouvez pas avec un client d'API invalide et déconnecté, mais vous pouvez simplement recréer le client d'API si vous recevez une erreur d'autorisation lors de la tentative d'exécution d'une requête.

public class RestApiClient
{
    public RestApiClient(HttpClient httpClient)
    {
        this.httpClient = new httpClient;
    }

    public async Task<RestApiClient> Create(string username, string password)
    {
        if (username == null)
        {
            throw new ArgumentNullException(nameof(username));
        }

        if (password == null)
        {
            throw new ArgumentNullException(nameof(password));
        }

        var basicAuthBytes = Encoding.ASCII.GetBytes($"{username}:{password}");
        var basicAuthValue = Convert.ToBase64String(basicAuthBytes);

        var authenticationHttpClient = new HttpClient
        {
            BaseUri = new Uri("https://auth.example.io"),
            DefaultRequestHeaders = {
                Authentication = new AuthenticationHeaderValue("Basic", basicAuthValue)
            }
        };

        using (authenticationHttpClient)
        {
            var response = await httpClient.GetAsync("login");
            var content = response.Content.ReadAsStringAsync();
            var authToken = content;
            var restApiHttpClient = new HttpClient
            {
                BaseUri = new Uri("https://api.example.io"), // notice this differs from the auth uri
                DefaultRequestHeaders = {
                    Authentication = new AuthenticationHeaderValue("Bearer", authToken)
                }
            };

            return new RestApiClient(restApiHttpClient);
        }
    }
}

Les inconvénients de cette méthode sont peu nombreux, d'après mon expérience.

En général, l'utilisation de cette méthodologie signifie que vous ne pouvez plus utiliser la classe comme DTO car la désérialisation vers un objet sans constructeur public par défaut est difficile, au mieux. Cependant, si vous utilisiez l'objet comme DTO, vous ne devriez pas vraiment valider l'objet lui-même, mais plutôt invalider les valeurs de l'objet lorsque vous tentez de les utiliser, puisque techniquement les valeurs ne sont pas "invalides" par rapport au DTO.

Cela signifie également que vous finirez par créer des méthodes ou des classes d'usine lorsque vous devez permettre à un conteneur IOC de créer l'objet, car sinon le conteneur ne saura pas comment instancier l'objet. Cependant, dans de nombreux cas, les méthodes d'usine finissent par être l'un des éléments suivants Create les méthodes elles-mêmes.

7voto

Michael L Perry Points 2380

En raison de tous les problèmes qu'une classe partiellement créée peut causer, je dirais jamais.

Si vous devez valider quelque chose pendant la construction, rendez le constructeur privé et définissez une méthode statique publique de la fabrique. La méthode peut lancer un appel si quelque chose n'est pas valide. Mais si tout se vérifie, elle appelle le constructeur, qui est assuré de ne pas être rejeté.

7 votes

Je dirais que c'est le contraire - si nous ne voulons pas d'objets partiellement créés, le constructeur debe lorsqu'il y a un problème - de cette façon, l'appelant saura que quelque chose a mal tourné.

1 votes

@TimBezhashvyly : Parce que ça ne correspond pas à la réalité. Dans le monde réel, certains échecs ne peuvent pas être prédits, seulement détectés.

0 votes

@BenVoigt ce n'est pas une excuse.

Prograide.com

Prograide est une communauté de développeurs qui cherche à élargir la connaissance de la programmation au-delà de l'anglais.
Pour cela nous avons les plus grands doutes résolus en français et vous pouvez aussi poser vos propres questions ou résoudre celles des autres.

Powered by:

X