J'ai récemment commencé à travailler dans un endroit avec des développeurs beaucoup plus âgés (environ 50 ans et plus). Ils ont travaillé sur des applications critiques concernant l'aviation où le système ne pouvait pas tomber en panne. En conséquence, l'ancien programmeur a tendance à coder de cette façon.
Il a tendance à mettre un booléen dans les objets pour indiquer si une exception doit être levée ou non.
Exemple
public class AreaCalculator
{
AreaCalculator(bool shouldThrowExceptions) { ... }
CalculateArea(int x, int y)
{
if(x < 0 || y < 0)
{
if(shouldThrowExceptions)
throwException;
else
return 0;
}
}
}
(Dans notre projet, la méthode peut échouer car nous essayons d'utiliser un périphérique réseau qui ne peut pas être présent à l'heure actuelle. L'exemple de zone n'est qu'un exemple du drapeau d'exception)
Pour moi, cela ressemble à une odeur de code. L'écriture de tests unitaires devient légèrement plus complexe car vous devez tester le drapeau d'exception à chaque fois. De plus, si quelque chose se passe mal, ne voudriez-vous pas le savoir tout de suite? Ne devrait-il pas être de la responsabilité de l'appelant de déterminer comment continuer?
Sa logique/raisonnement est que notre programme doit faire 1 chose, montrer les données à l'utilisateur. Toute autre exception qui ne nous y empêche pas doit être ignorée. Je suis d'accord qu'ils ne devraient pas être ignorés, mais devraient bouillonner et être gérés par la personne appropriée, et ne pas avoir à traiter avec des drapeaux pour cela.
Est-ce un bon moyen de gérer les exceptions?
Edit: Juste pour donner plus de contexte sur la décision de conception, je soupçonne que c'est parce que si ce composant échoue, le programme peut toujours fonctionner et faire sa tâche principale. Ainsi, nous ne voudrions pas lever une exception (et ne pas la gérer?) Et lui faire retirer le programme alors que pour l'utilisateur son bon fonctionnement
Edit 2: Pour donner encore plus de contexte, dans notre cas, la méthode est appelée pour réinitialiser une carte réseau. Le problème survient lorsque la carte réseau est déconnectée et reconnectée, une adresse IP différente lui est affectée, donc Reset lèvera une exception car nous essaierions de réinitialiser le matériel avec l'ancienne IP.
Le problème avec cette approche est que même si les exceptions ne sont jamais levées (et donc, l'application ne se bloque jamais en raison d'exceptions non interceptées), les résultats renvoyés ne sont pas nécessairement corrects, et l'utilisateur peut ne jamais savoir qu'il y a un problème avec les données (ou quel est ce problème et comment le corriger).
Pour que les résultats soient corrects et significatifs, la méthode appelante doit vérifier le résultat pour les nombres spéciaux - c'est-à-dire les valeurs de retour spécifiques utilisées pour dénoter les problèmes qui sont survenus lors de l'exécution de la méthode. Les nombres négatifs (ou nuls) renvoyés pour des quantités définies positives (comme la zone) en sont un excellent exemple dans un code plus ancien. Si la méthode appelante ne sait pas (ou oublie!) De vérifier ces numéros spéciaux, le traitement peut se poursuivre sans jamais se rendre compte d'une erreur. Les données sont ensuite affichées pour l'utilisateur montrant une zone de 0, dont l'utilisateur sait qu'elle est incorrecte, mais ils n'ont aucune indication de ce qui s'est mal passé, où et pourquoi. Ils se demandent alors si l'une des autres valeurs est fausse ...
Si l'exception était levée, le traitement s'arrêterait, l'erreur serait (idéalement) enregistrée et l'utilisateur pourrait être notifié d'une manière ou d'une autre. L'utilisateur peut alors corriger ce qui ne va pas et réessayer. Une bonne gestion des exceptions (et des tests!) Garantira que les applications critiques ne se bloquent pas ou ne se retrouvent pas dans un état non valide.
Est-ce un bon moyen de gérer les exceptions?
Non, je pense que c'est une assez mauvaise pratique. Lancer une exception par rapport au retour d'une valeur est un changement fondamental dans l'API, changer la signature de la méthode et faire en sorte que la méthode se comporte très différemment du point de vue de l'interface.
En général, lorsque nous concevons des classes et leurs API, nous devons considérer que
il peut y avoir plusieurs instances de la classe avec différentes configurations flottant dans le même programme en même temps, et,
en raison de l'injection de dépendances et d'un certain nombre d'autres pratiques de programmation, un client consommateur peut créer les objets et les remettre à un autre autre les utiliser - nous avons donc souvent une séparation entre les créateurs d'objets et les utilisateurs d'objets.
Considérez maintenant ce que l'appelant doit faire pour utiliser une instance qui lui a été remise, par exemple pour appeler la méthode de calcul: l'appelant devrait à la fois vérifier que la zone est zéro et intercepter les exceptions - aïe! Les considérations de test ne concernent pas seulement la classe elle-même, mais aussi la gestion des erreurs des appelants ...
Nous devons toujours rendre les choses aussi faciles que possible pour le client consommateur; cette configuration booléenne dans le constructeur qui change l'API d'une méthode d'instance est l'opposé de faire tomber le programmeur client consommateur (peut-être vous ou votre collègue) dans le gouffre du succès.
Pour offrir les deux API, vous êtes beaucoup mieux et plus normal de fournir deux classes différentes - une qui génère toujours une erreur et une qui renvoie toujours 0 en cas d'erreur, ou, fournissez deux méthodes différentes avec une seule classe. De cette façon, le client consommateur peut facilement savoir exactement comment rechercher et gérer les erreurs.
En utilisant deux classes différentes ou deux méthodes différentes, vous pouvez utiliser les IDEs find method users et refactor features, etc. beaucoup plus facilement car les deux cas d'utilisation ne sont plus confondus. La lecture, l'écriture, la maintenance, les révisions et les tests de code sont également plus simples.
Sur une autre note, je pense personnellement que nous ne devrions pas prendre de paramètres de configuration booléens, où les appelants réels passent tous simplement une constante. Un tel paramétrage de configuration confond deux cas d'utilisation distincts sans aucun avantage réel.
Jetez un œil à votre base de code et voyez si une variable (ou une expression non constante) est déjà utilisée pour le paramètre de configuration booléen dans le constructeur! J'en doute.
Et il faut également se demander pourquoi le calcul de la zone peut échouer. Le mieux pourrait être de jeter le constructeur, si le calcul ne peut pas être fait. Cependant, si vous ne savez pas si le calcul peut être effectué jusqu'à ce que l'objet soit encore initialisé, envisagez peut-être d'utiliser différentes classes pour différencier ces états (pas prêt à calculer la zone vs prêt à calculer la zone).
J'ai lu que votre situation d'échec est orientée vers l'accès à distance, donc peut ne pas s'appliquer; Seulement un peu de matière à réflexion.
Ne devrait-il pas être de la responsabilité de l'appelant de déterminer comment continuer?
Oui je suis d'accord. Il semble prématuré pour l'appelé de décider que la zone de 0 est la bonne réponse dans des conditions d'erreur (d'autant plus que 0 est une zone valide, donc aucun moyen de faire la différence entre l'erreur et le 0 réel, mais peut ne pas s'appliquer à votre application).
Ils ont travaillé sur des applications critiques concernant l'aviation où le système ne pouvait pas tomber en panne. Par conséquent ...
C'est une introduction intéressante, qui me donne l'impression que la motivation derrière cette conception est d'éviter de lever des exceptions dans certains contextes "car le système pourrait tomber en panne" puis. Mais si le système "peut tomber en raison d'une exception", cela indique clairement que
Donc, si le programme qui utilise le AreaCalculator
est bogué, votre collègue préfère ne pas avoir le programme "planté tôt", mais renvoyer une valeur incorrecte (en espérant que personne ne le remarque, ou en espérant que personne ne fasse quelque chose d'important avec lui) ). C'est en fait masquant une erreur, et d'après mon expérience, cela conduira tôt ou tard à des bogues de suivi pour lesquels il devient difficile de trouver la cause première.
À mon humble avis, l'écriture d'un programme qui ne se bloque en aucun cas mais affiche des données ou des résultats de calcul incorrects n'est généralement pas meilleure que de laisser le programme se bloquer. La seule bonne approche consiste à donner à l'appelant la possibilité de remarquer l'erreur, de la traiter, de le laisser décider si l'utilisateur doit être informé du mauvais comportement et s'il est sûr de poursuivre tout traitement ou s'il est plus sûr. pour arrêter complètement le programme. Ainsi, je recommanderais l'une des options suivantes:
il est difficile d'oublier qu'une fonction peut lever une exception. Les normes de documentation et de codage sont vos amis ici, et des révisions de code régulières devraient prendre en charge l'utilisation correcte des composants et la gestion correcte des exceptions.
former l'équipe à s'attendre et à gérer les exceptions lorsqu'elle utilise des composants de type "boîte noire" et avoir à l'esprit le comportement global du programme.
si, pour certaines raisons, vous pensez que vous ne pouvez pas obtenir le code appelant (ou les développeurs qui l'écrivent) pour utiliser correctement la gestion des exceptions, alors, en dernier recours, vous pouvez concevoir une API avec des variables de sortie d'erreur explicites et aucune exception, comme
CalculateArea(int x, int y, out ErrorCode err)
il devient donc très difficile pour l'appelant d'ignorer la fonction pourrait échouer. Mais c'est IMHO extrêmement laid en C #; c'est une ancienne technique de programmation défensive de C où il n'y a pas d'exceptions, et il ne devrait normalement pas être nécessaire de travailler cela de nos jours.
L'écriture de tests unitaires devient légèrement plus complexe car vous devez tester le drapeau d'exception à chaque fois.
Toute fonction avec des paramètres n sera plus difficile à tester qu'une fonction avec des paramètres n-1. Étendez cela à l'absurde et l'argument devient que les fonctions ne devraient pas avoir de paramètres du tout, car cela les rend plus faciles à tester.
Bien que ce soit une excellente idée d'écrire du code facile à tester, c'est une terrible idée de mettre la simplicité du test au-dessus de l'écriture de code utile aux personnes qui doivent l'appeler. Si l'exemple de la question comporte un commutateur qui détermine si une exception est levée ou non, il est possible que les appelants souhaitant ce comportement aient mérité de l'ajouter à la fonction. Où la frontière entre le complexe et le trop complexe se situe est un appel au jugement; quiconque essaie de vous dire qu'il existe une ligne claire qui s'applique dans toutes les situations doit être regardé avec suspicion.
De plus, si quelque chose se passe mal, ne voudriez-vous pas le savoir tout de suite?
Cela dépend de votre définition du mal. L'exemple de la question définit l'erreur comme "étant donné une dimension inférieure à zéro et shouldThrowExceptions
est vrai". Une dimension si inférieure à zéro n'est pas incorrecte lorsque shouldThrowExceptions
est faux car le commutateur induit un comportement différent. Ce n'est tout simplement pas une situation exceptionnelle.
Le vrai problème ici est que le commutateur a été mal nommé car il ne décrit pas ce qu'il fait faire à la fonction. Si on lui avait donné un meilleur nom comme treatInvalidDimensionsAsZero
, auriez-vous posé cette question?
Ne devrait-il pas être de la responsabilité de l'appelant de déterminer comment continuer?
L'appelant fait détermine comment continuer. Dans ce cas, il le fait à l'avance en définissant ou en effaçant shouldThrowExceptions
et la fonction se comporte en fonction de son état.
L'exemple est pathologiquement simple car il effectue un seul calcul et renvoie. Si vous le rendez un peu plus complexe, comme le calcul de la somme des racines carrées d'une liste de nombres, lever des exceptions peut donner aux appelants des problèmes qu'ils ne peuvent pas résoudre. Si je passe une liste de [5, 6, -1, 8, 12]
et la fonction lève une exception sur -1
, Je n'ai aucun moyen de dire à la fonction de continuer car elle aura déjà abandonné et jeté la somme. Si la liste est un énorme ensemble de données, il peut être impossible de générer une copie sans aucun nombre négatif avant d'appeler la fonction, donc je suis obligé de dire à l'avance comment les nombres invalides doivent être traités, soit sous la forme d'un "il suffit de les ignorer" "changer ou peut-être fournir un lambda qui sera appelé pour prendre cette décision.
Sa logique/raisonnement est que notre programme doit faire 1 chose, montrer les données à l'utilisateur. Toute autre exception qui ne nous y empêche pas doit être ignorée. Je suis d'accord qu'ils ne devraient pas être ignorés, mais devraient bouillonner et être gérés par la personne appropriée, et ne pas avoir à traiter avec des drapeaux pour cela.
Encore une fois, il n'y a pas de solution unique. Dans l'exemple, la fonction a vraisemblablement été écrite dans une spécification qui indique comment gérer les dimensions négatives. La dernière chose que vous voulez faire est de réduire le rapport signal/bruit de vos journaux en les remplissant de messages qui disent "normalement, une exception serait levée ici, mais l'appelant a dit de ne pas déranger."
Et en tant que programmeur beaucoup plus âgé, je vous demanderais de bien vouloir quitter ma pelouse. ;-)
Un code critique et "normal" pour la sécurité peut conduire à des idées très différentes de ce à quoi ressemblent les "bonnes pratiques". Il y a beaucoup de chevauchements - certaines choses sont risquées et devraient être évitées dans les deux - mais il y a encore des différences importantes. Si vous ajoutez une exigence pour garantir la réactivité, ces écarts deviennent assez importants.
Ceux-ci sont souvent liés à des choses auxquelles vous vous attendez:
Pour git, la mauvaise réponse pourrait être très mauvaise par rapport à: prendre trop de temps/abandonner/suspendre ou même planter (qui ne sont en fait pas des problèmes par rapport, disons, à la modification accidentelle du code enregistré).
Cependant: Pour un tableau de bord ayant un calcul de la force g au point mort et empêchant un calcul de la vitesse de l'air en cours, peut être inacceptable.
Certains sont moins évidents:
Si vous avez testé un lot , les résultats de premier ordre (comme les bonnes réponses) ne sont pas aussi préoccupants relativement parlant. Vous savez que vos tests auront couvert cela. Cependant, s'il y avait un état caché ou un flux de contrôle, vous ne savez pas que cela ne sera pas la cause de quelque chose de beaucoup plus subtil. C'est difficile à exclure avec les tests.
Être manifestement sûr est relativement important. Peu de clients s'asseyent pour savoir si la source qu'ils achètent est sûre ou non. Si vous êtes sur le marché de l'aviation d'autre part ...
Comment cela s'applique-t-il à votre exemple:
Je ne sais pas. Il existe un certain nombre de processus de réflexion qui pourraient avoir des règles de base comme "personne ne jette dans le code de production" étant adopté dans un code critique de sécurité qui serait assez idiot dans des situations plus courantes.
Certains seront liés à l'intégration, certains à la sécurité et peut-être d'autres ... Certains sont bons (des performances/limites de mémoire strictes étaient nécessaires), certains sont mauvais (nous ne gérons pas les exceptions correctement, il vaut donc mieux ne pas le risquer). La plupart du temps, même savoir pourquoi ils l'ont fait ne répondra pas vraiment à la question. Par exemple, si cela a à voir avec la facilité d'auditer davantage le code que de l'améliorer réellement, est-ce une bonne pratique? Vous ne pouvez vraiment pas le dire. Ce sont des animaux différents et doivent être traités différemment.
Cela dit, cela me semble un peu suspect ( [~ # ~] mais [~ # ~] :
Les décisions relatives à la sécurité des logiciels et à la conception de logiciels ne devraient probablement pas être prises par des étrangers lors de l'échange de piles d'ingénierie logicielle. Il peut y avoir une bonne raison de le faire, même si cela fait partie d'un mauvais système. Ne lisez pas grand-chose dans tout cela autre que comme "matière à réflexion".
Parfois, lever une exception n'est pas la meilleure méthode. Notamment en raison du déroulement de la pile, mais parfois parce que la capture d'une exception est problématique, en particulier le long des coutures de langue ou d'interface.
Un bon moyen de gérer cela est de renvoyer un type de données enrichi. Ce type de données a un état suffisant pour décrire tous les chemins heureux et tous les chemins malheureux. Le fait est que si vous interagissez avec cette fonction (membre/global/sinon), vous serez obligé de gérer le résultat.
Cela étant dit, ce type de données enrichi ne doit pas forcer à agir. Imaginez dans votre exemple de zone quelque chose comme var area_calc = new AreaCalculator(); var volume = area_calc.CalculateArea(x, y) * z;
. Semble utile volume
devrait contenir la surface multipliée par la profondeur - qui pourrait être un cube, un cylindre, etc.
Mais que faire si le service area_calc était en panne? Ensuite, area_calc .CalculateArea(x, y)
a renvoyé un type de données riche contenant une erreur. Est-il légal de multiplier cela par z
? C'est une bonne question. Vous pouvez forcer les utilisateurs à gérer la vérification immédiatement. Cela rompt cependant la logique avec la gestion des erreurs.
var area_calc = new AreaCalculator();
var area_result = area_calc.CalculateArea(x, y);
if (area_result.bad())
{
//handle unhappy path
}
var volume = area_result.value() * z;
contre
var area_calc = new AreaCalculator();
var volume = area_calc.CalculateArea(x, y) * z;
if (volume.bad())
{
//handle unhappy path
}
La logique essentiellement est répartie sur deux lignes et divisée par le traitement des erreurs dans le premier cas, tandis que le deuxième cas a toute la logique pertinente sur une ligne suivie d'un traitement des erreurs.
Dans ce deuxième cas, volume
est un type de données riche. Ce n'est pas seulement un chiffre. Cela augmente le stockage et volume
devra encore être recherché pour une condition d'erreur. De plus, volume
peut alimenter d'autres calculs avant que l'utilisateur ne choisisse de gérer l'erreur, lui permettant ainsi de se manifester dans plusieurs emplacements disparates. Cela peut être bon ou mauvais selon les spécificités de la situation.
Alternativement, volume
pourrait être simplement un type de données ordinaire - juste un nombre, mais qu'advient-il de la condition d'erreur? Il se pourrait que la valeur soit implicitement convertie si elle est dans un état heureux. S'il est dans un état malheureux, il peut renvoyer une valeur par défaut/erreur (pour la zone 0 ou -1, cela peut sembler raisonnable). Alternativement, il pourrait lever une exception de ce côté de la frontière interface/langue.
... foo() {
var area_calc = new AreaCalculator();
return area_calc.CalculateArea(x, y) * z;
}
var volume = foo();
if (volume <= 0)
{
//handle error
}
vs.
... foo() {
var area_calc = new AreaCalculator();
return area_calc.CalculateArea(x, y) * z;
}
try { var volume = foo(); }
catch(...)
{
//handle error
}
En distribuant une mauvaise, voire une mauvaise valeur, il incombe à l'utilisateur de valider les données. C'est une source de bugs, car pour le compilateur la valeur de retour est un entier légitime. Si quelque chose n'a pas été vérifié, vous le découvrirez lorsque les choses tournent mal. Le deuxième cas mélange le meilleur des deux mondes en permettant aux exceptions de gérer les chemins malheureux, tandis que les chemins heureux suivent un traitement normal. Malheureusement, cela oblige l'utilisateur à gérer les exceptions à bon escient, ce qui est difficile.
Juste pour être clair, un chemin malheureux est un cas inconnu de la logique métier (le domaine d'exception), ne pas valider est un chemin heureux parce que vous savez comment gérer cela par les règles métier (le domaine des règles).
La solution ultime serait celle qui permet tous les scénarios (dans des limites raisonnables).
Quelque chose comme:
Rich::value_type value_or_default(Rich&, Rich::value_type default_value = ...);
bool bad(Rich&);
...unhappy path report... bad_state(Rich&);
Rich& assert_not_bad(Rich&);
class Rich
{
public:
typedef ... value_type;
operator value_type() { assert_not_bad(*this); return ...value...; }
operator X(...) { if (bad(*this)) return ...propagate badness to new value...; /*operate and generate new value*/; }
}
//check
if (bad(x))
{
var report = bad_state(x);
//handle error
}
//rethrow
assert_not_bad(x);
var result = (assert_not_bad(x) + 23) / 45;
//propogate
var y = x * 23;
//implicit throw
Rich::value_type val = x;
var val = ((Rich::value_type)x) + 34;
var val2 = static_cast<Rich::value_type>(x) % 3;
//default value
var defaulted = value_or_default(x);
var defaulted_to = value_or_default(x, 55);
Je répondrai du point de vue du C++. Je suis presque sûr que tous les concepts de base sont transférables en C #.
Il semble que votre style préféré soit "toujours lever les exceptions":
int CalculateArea(int x, int y) {
if (x < 0 || y < 0) {
throw Exception("negative side lengths");
}
return x * y;
}
Cela peut être un problème pour le code C++ car la gestion des exceptions est lourde - elle fait fonctionner le cas d'échec lentement et oblige le cas d'échec à allouer de la mémoire (qui parfois même pas disponible), et rend généralement les choses moins prévisibles. La lourdeur de l'EH est l'une des raisons pour lesquelles vous entendez des gens dire des choses comme "N'utilisez pas d'exceptions pour le flux de contrôle".
Ainsi, certaines bibliothèques (telles que <filesystem>
) utilisez ce que C++ appelle une "double API" ou ce que C # appelle Try-Parse
pattern (merci Peter pour le conseil!)
int CalculateArea(int x, int y) {
if (x < 0 || y < 0) {
throw Exception("negative side lengths");
}
return x * y;
}
bool TryCalculateArea(int x, int y, int& result) {
if (x < 0 || y < 0) {
return false;
}
result = x * y;
return true;
}
int a1 = CalculateArea(x, y);
int a2;
if (TryCalculateArea(x, y, a2)) {
// use a2
}
Vous pouvez voir le problème avec les "doubles API" tout de suite: beaucoup de duplication de code, aucune indication pour les utilisateurs quant à quelle API est la "bonne" à utiliser, et l'utilisateur doit faire un choix difficile entre messages d'erreur utiles (CalculateArea
) et vitesse (TryCalculateArea
) parce que la version plus rapide prend notre utile "negative side lengths"
exception et l'aplatit en un false
- "quelque chose s'est mal passé, ne me demandez pas quoi ni où." (Certaines API doubles utilisent un type d'erreur plus expressif, tel que int errno
ou C++ std::error_code
, mais cela ne vous dit toujours pas où l'erreur s'est produite - juste qu'elle a fait se produire quelque part.)
Si vous ne pouvez pas décider comment votre code doit se comporter, vous pouvez toujours donner la décision à l'appelant!
template<class F>
int CalculateArea(int x, int y, F errorCallback) {
if (x < 0 || y < 0) {
return errorCallback(x, y, "negative side lengths");
}
return x * y;
}
int a1 = CalculateArea(x, y, [](auto...) { return 0; });
int a2 = CalculateArea(x, y, [](int, int, auto msg) { throw Exception(msg); });
int a3 = CalculateArea(x, y, [](int, int, auto) { return x * y; });
C'est essentiellement ce que fait votre collègue; sauf qu'il factorise le "gestionnaire d'erreurs" dans une variable globale:
std::function<int(const char *)> g_errorCallback;
int CalculateArea(int x, int y) {
if (x < 0 || y < 0) {
return g_errorCallback("negative side lengths");
}
return x * y;
}
g_errorCallback = [](auto) { return 0; };
int a1 = CalculateArea(x, y);
g_errorCallback = [](const char *msg) { throw Exception(msg); };
int a2 = CalculateArea(x, y);
Le déplacement de paramètres importants de paramètres de fonction explicites vers état global est presque toujours un mauvaise idée. Je ne le recommande pas. (Le fait qu'il ne s'agit pas de l'état global dans votre cas mais simplement à l'échelle de l'instance l'État membre atténue un peu la méchanceté, mais pas beaucoup.)
De plus, votre collègue limite inutilement le nombre de comportements possibles de gestion des erreurs. Plutôt que d'autoriser tout lambda de gestion des erreurs , il a décidé de n'en prendre que deux:
bool g_errorViaException;
int CalculateArea(int x, int y) {
if (x < 0 || y < 0) {
return g_errorViaException ? throw Exception("negative side lengths") : 0;
}
return x * y;
}
g_errorViaException = false;
int a1 = CalculateArea(x, y);
g_errorViaException = true;
int a2 = CalculateArea(x, y);
C'est probablement le "point aigre" de l'une de ces stratégies possibles. Vous avez retiré toute la flexibilité à l'utilisateur final en l'obligeant à utiliser l'un de vos exactement deux rappels de gestion des erreurs; et vous avez tous les problèmes de l'état global partagé; et vous payez toujours pour cette branche conditionnelle partout.
Enfin, une solution courante en C++ (ou n'importe quel langage avec compilation conditionnelle) serait de forcer l'utilisateur à prendre la décision pour l'ensemble de son programme, globalement, au moment de la compilation, afin que le chemin de code non pris puisse être entièrement optimisé:
int CalculateArea(int x, int y) {
if (x < 0 || y < 0) {
#ifdef NEXCEPTIONS
return 0;
#else
throw Exception("negative side lengths");
#endif
}
return x * y;
}
// Now these two function calls *must* have the same behavior,
// which is a Nice property for a program to have.
// Improves understandability.
//
int a1 = CalculateArea(x, y);
int a2 = CalculateArea(x, y);
Un exemple de quelque chose qui fonctionne de cette façon est la macro assert
en C et C++, qui conditionne son comportement sur la macro de préprocesseur NDEBUG
.
Je pense qu'il faut mentionner d'où votre collègue a tiré son modèle.
De nos jours, C # a le modèle TryGet public bool TryThing(out result)
. Cela vous permet d'obtenir votre résultat, tout en vous permettant de savoir si ce résultat est même une valeur valide. (Par exemple, toutes les valeurs de int
sont des résultats valides pour Math.sum(int, int)
, mais si la valeur devait déborder, ce résultat particulier pourrait être une ordure). Il s'agit cependant d'un modèle relativement nouveau.
Avant le mot-clé out
, vous deviez soit lever une exception (cher, et l'appelant doit l'attraper ou tuer tout le programme), créer une structure spéciale (classe avant classe ou les génériques étaient vraiment une chose) pour chaque résultat pour représenter la valeur et les erreurs possibles (temps pour créer et gonfler le logiciel), ou renvoyer une valeur par défaut "erreur" (qui pourrait ne pas être une erreur).
L'approche utilisée par votre collègue leur donne l'avantage d'échouer au début des exceptions lors du test/débogage de nouvelles fonctionnalités, tout en leur donnant la sécurité et les performances d'exécution (les performances étaient un problème critique il y a environ 30 ans) de simplement renvoyer une valeur d'erreur par défaut. Maintenant, c'est le modèle dans lequel le logiciel a été écrit, et le modèle attendu va de l'avant, il est donc naturel de continuer à le faire de cette façon, même s'il existe de meilleures façons maintenant. Très probablement, ce modèle a été hérité de l'âge du logiciel, ou un modèle que vos collèges n'ont jamais développé (les vieilles habitudes sont difficiles à briser).
Les autres réponses expliquent déjà pourquoi cela est considéré comme une mauvaise pratique, je terminerai donc en vous recommandant de lire le modèle TryGet (peut-être aussi l'encapsulation pour ce que les promesses qu'un objet devrait faire à son appelant).
Cet exemple spécifique a une fonctionnalité intéressante qui peut affecter les règles ...
CalculateArea(int x, int y)
{
if(x < 0 || y < 0)
{
if(shouldThrowExceptions)
throwException;
else
return 0;
}
}
Ce que je vois ici est une précondition vérification. Un échec de vérification des conditions implique un bogue plus haut dans la pile des appels. Ainsi, la question devient est ce code responsable de signaler les bogues situés ailleurs?
Une partie de la tension ici est attribuable au fait que cette interface présente obsession primitive - x
et y
sont vraisemblablement censés représenter des mesures réelles de la longueur. Dans un contexte de programmation où les types spécifiques de domaines sont un choix raisonnable, nous déplacerions en fait la vérification des conditions préalables plus près de la source des données - en d'autres termes, bousculerons la responsabilité de l'intégrité des données plus haut dans la pile des appels, où nous avons un meilleur sens du contexte.
Cela dit, je ne vois rien de fondamentalement mal à avoir deux stratégies différentes pour gérer un chèque échoué. Ma préférence serait d'utiliser la composition pour déterminer quelle stratégie est utilisée; l'indicateur de fonctionnalité serait utilisé dans la racine de composition, plutôt que dans l'implémentation de la méthode de bibliothèque.
// Configurable dependencies
AreaCalculator(PreconditionFailureStrategy strategy)
CalculateArea(int x, int y)
{
if (x < 0 || y < 0) {
return this.strategy.fail(0);
}
// ...
}
Ils ont travaillé sur des applications critiques concernant l'aviation où le système ne pouvait pas tomber en panne.
Le National Traffic and Safety Board est vraiment bon; Je pourrais suggérer des techniques de mise en œuvre alternatives aux barbes grises, mais je ne suis pas enclin à discuter avec eux de la conception de cloisons dans le sous-système de rapport d'erreurs.
Plus largement: quel est le coût pour l'entreprise? Il est beaucoup moins cher de faire planter un site Web qu'un système vital.
Il y a des moments où vous voulez faire son approche, mais je ne les considérerais pas comme la situation "normale". La clé pour déterminer dans quel cas vous vous trouvez est:
Sa logique/raisonnement est que notre programme doit faire 1 chose, montrer les données à l'utilisateur. Toute autre exception qui ne nous y empêche pas doit être ignorée.
Vérifiez les exigences. Si vos exigences indiquent réellement que vous avez un travail, qui est de montrer des données à l'utilisateur, alors il a raison. Cependant, d'après mon expérience, la plupart du temps, l'utilisateur aussi se soucie des données affichées. Ils veulent les données correctes. Certains systèmes veulent simplement échouer discrètement et laisser un utilisateur comprendre que quelque chose s'est mal passé, mais je les considérerais comme l'exception à la règle.
La question clé que je poserais après un échec est "Le système est-il dans un état où les attentes de l'utilisateur et les invariants du logiciel sont valides?" Si c'est le cas, alors revenez certainement et continuez. En pratique, ce n'est pas ce qui se passe dans la plupart des programmes.
Quant à l'indicateur lui-même, l'indicateur d'exceptions est généralement considéré comme une odeur de code car un utilisateur doit en quelque sorte savoir dans quel mode se trouve le module afin de comprendre comment fonctionne la fonction. Si c'est dans !shouldThrowExceptions
mode, l'utilisateur doit savoir que they est responsable de la détection des erreurs et du maintien des attentes et des invariants lorsqu'ils se produisent. Ils sont également responsables sur-le-champ, sur la ligne où la fonction est appelée. Un drapeau comme celui-ci est généralement très déroutant.
Cependant, cela arrive. Considérez que de nombreux processeurs permettent de modifier le comportement en virgule flottante dans le programme. Un programme qui souhaite avoir des normes plus souples peut le faire simplement en changeant un registre (qui est en fait un indicateur). L'astuce est que vous devez être très prudent, pour éviter de marcher accidentellement sur les autres orteils. Le code vérifie souvent l'indicateur actuel, le définit sur le paramètre souhaité, effectue des opérations, puis le réinitialise. De cette façon, personne ne sera surpris par le changement.