Est-il possible d'écrire trop d'assertions?
Bien sûr que oui. [Imaginez un exemple odieux ici.] Cependant, en appliquant les directives détaillées ci-dessous, vous ne devriez pas avoir de difficulté à repousser cette limite dans la pratique. Je suis également un grand partisan des assertions et les utilise conformément à ces principes. Une grande partie de ces conseils ne sont pas particuliers aux assertions, mais seulement aux bonnes pratiques générales d'ingénierie qui leur sont appliquées.
Gardez à l'esprit les frais généraux d'exécution et d'empreinte binaire
Les affirmations sont excellentes, mais si elles ralentissent votre programme de manière inacceptable, ce sera soit très agaçant, soit vous les désactiverez tôt ou tard.
J'aime jauger le coût d'une assertion par rapport au coût de la fonction dans laquelle elle est contenue. Considérons les deux exemples suivants.
// Precondition: queue is not empty
// Invariant: queue is sorted
template <typename T>
const T&
sorted_queue<T>::max() const noexcept
{
assert(!this->data_.empty());
assert(std::is_sorted(std::cbegin(this->data_), std::cend(this->data_)));
return this->data_.back();
}
La fonction elle-même est une opération O (1), mais les assertions prennent en compte le temps système O ( n ). Je ne pense pas que vous souhaitiez que ces contrôles soient actifs sauf dans des circonstances très spéciales.
Voici une autre fonction avec des assertions similaires.
// Requirement: op : T -> T is monotonic [ie x <= y implies op(x) <= op(y)]
// Invariant: queue is sorted
// Postcondition: each item x in the queue is replaced by op(x)
template <typename T>
template <typename FuncT>
void
sorted_queue<T>::apply_monotonic_function(FuncT&& op)
{
assert(std::is_sorted(std::cbegin(this->data_), std::cend(this->data_)));
std::transform(std::cbegin(this->data_), std::cend(this->data_),
std::begin(this->data_), std::forward<FuncT>(op));
assert(std::is_sorted(std::cbegin(this->data_), std::cend(this->data_)));
}
La fonction elle-même est une opération O ( n ), il est donc beaucoup moins difficile d’ajouter une surcharge O ( n ) supplémentaire pour l’assertion. Ralentir une fonction d'un petit facteur constant (dans ce cas, probablement inférieur à 3) est quelque chose que nous pouvons généralement nous permettre dans une version de débogage, mais peut-être pas dans une version finale.
Considérons maintenant cet exemple.
// Precondition: queue is not empty
// Invariant: queue is sorted
// Postcondition: last element is removed from queue
template <typename T>
void
sorted_queue<T>::pop_back() noexcept
{
assert(!this->data_.empty());
return this->data_.pop_back();
}
Alors que beaucoup de gens seront probablement beaucoup plus à l'aise avec cette affirmation O (1) qu'avec les deux affirmations O ( n ) de l'exemple précédent, elles sont moralement équivalentes à mon avis. Chacun ajoute une surcharge sur l'ordre de la complexité de la fonction elle-même.
Enfin, il y a les affirmations «vraiment bon marché» qui sont dominées par la complexité de la fonction dans laquelle elles sont contenues.
// Requirement: cmp : T x T -> bool is a strict weak ordering
// Precondition: queue is not empty
// Postcondition: if x is returned, then there is no y in the queue
// such that cmp(x, y)
template <typename T>
template <typename CmpT>
const T&
sorted_queue<T>::max(CmpT&& cmp) const
{
assert(!this->data_.empty());
const auto pos = std::max_element(std::cbegin(this->data_),
std::cend(this->data_),
std::forward<CmpT>(cmp));
assert(pos != std::cend(this->data_));
return *pos;
}
Ici, nous avons deux assertions O (1) dans une fonction O ( n ). Ce ne sera probablement pas un problème de conserver ces frais généraux, même dans les versions finales.
Cependant, gardez à l'esprit que les complexités asymptotiques ne donnent pas toujours une estimation adéquate car, dans la pratique, nous traitons toujours avec des tailles d'entrées limitées par des facteurs finis constants et constants cachés par “Big- O ” qui pourraient bien ne pas être négligeables.
Alors maintenant que nous avons identifié différents scénarios, que pouvons-nous faire à leur sujet? Une approche (probablement trop) facile consisterait à suivre une règle telle que «N'utilisez pas les assertions qui dominent la fonction dans laquelle elles sont contenues». Même si cela peut fonctionner pour certains projets, d'autres peuvent nécessiter une approche plus différenciée. Cela pourrait être fait en utilisant différentes macros d'assertion pour les différents cas.
#define MY_ASSERT_IMPL(COST, CONDITION) \
( \
( ((COST) <= (MY_ASSERT_COST_LIMIT)) && !(CONDITION) ) \
? ::my::assertion_failed(__FILE__, __LINE__, __FUNCTION__, # CONDITION) \
: (void) 0 \
)
#define MY_ASSERT_LOW(CONDITION) \
MY_ASSERT_IMPL(MY_ASSERT_COST_LOW, CONDITION)
#define MY_ASSERT_MEDIUM(CONDITION) \
MY_ASSERT_IMPL(MY_ASSERT_COST_MEDIUM, CONDITION)
#define MY_ASSERT_HIGH(CONDITION) \
MY_ASSERT_IMPL(MY_ASSERT_COST_HIGH, CONDITION)
#define MY_ASSERT_COST_NONE 0
#define MY_ASSERT_COST_LOW 1
#define MY_ASSERT_COST_MEDIUM 2
#define MY_ASSERT_COST_HIGH 3
#define MY_ASSERT_COST_ALL 10
#ifndef MY_ASSERT_COST_LIMIT
# define MY_ASSERT_COST_LIMIT MY_ASSERT_COST_MEDIUM
#endif
namespace my
{
[[noreturn]] extern void
assertion_failed(const char * filename, int line, const char * function,
const char * message) noexcept;
}
Vous pouvez maintenant utiliser les trois macros MY_ASSERT_LOW
, MY_ASSERT_MEDIUM
et MY_ASSERT_HIGH
au lieu de la assert
macro "one size fits all" de la bibliothèque standard pour les assertions dominées, ni dominées, ni dominantes et dominant la complexité de leur fonction contenant respectivement. Lorsque vous créez le logiciel, vous pouvez prédéfinir le symbole de pré-processeur MY_ASSERT_COST_LIMIT
pour sélectionner le type d'assertions à inclure dans l'exécutable. Les constantes MY_ASSERT_COST_NONE
et MY_ASSERT_COST_ALL
ne correspondent pas aux macros assert et sont destinés à être utilisés comme valeurs MY_ASSERT_COST_LIMIT
afin de mettre toutes les affirmations hors ou sur respectivement.
Nous nous appuyons sur l'hypothèse ici qu'un bon compilateur ne générera aucun code pour
if (false_constant_expression && run_time_expression) { /* ... */ }
et transformer
if (true_constant_expression && run_time_expression) { /* ... */ }
dans
if (run_time_expression) { /* ... */ }
que je crois est une hypothèse sûre de nos jours.
Si vous êtes sur le point de modifier le code ci-dessus, envisagez des annotations spécifiques au compilateur, comme __attribute__ ((cold))
sur my::assertion_failed
ou __builtin_expect(…, false)
sur, !(CONDITION)
pour réduire la surcharge des assertions passées. Dans les versions de version, vous pouvez également envisager de remplacer l'appel de fonction my::assertion_failed
par quelque chose comme __builtin_trap
pour réduire l'encombrement au lieu de perdre un message de diagnostic.
Ces types d’optimisation ne sont vraiment pertinents que dans des assertions extrêmement bon marché (comme comparer deux entiers déjà donnés en arguments) dans une fonction elle-même très compacte, sans tenir compte de la taille supplémentaire du binaire accumulé en incorporant toutes les chaînes de message.
Comparez comment ce code
int
positive_difference_1st(const int a, const int b) noexcept
{
if (!(a > b))
my::assertion_failed(__FILE__, __LINE__, __FUNCTION__, "!(a > b)");
return a - b;
}
est compilé dans l'assemblage suivant
_ZN4test23positive_difference_1stEii:
.LFB0:
.cfi_startproc
cmpl %esi, %edi
jle .L5
movl %edi, %eax
subl %esi, %eax
ret
.L5:
subq $8, %rsp
.cfi_def_cfa_offset 16
movl $.LC0, %ecx
movl $_ZZN4test23positive_difference_1stEiiE12__FUNCTION__, %edx
movl $50, %esi
movl $.LC1, %edi
call _ZN2my16assertion_failedEPKciS1_S1_
.cfi_endproc
.LFE0:
tandis que le code suivant
int
positive_difference_2nd(const int a, const int b) noexcept
{
if (__builtin_expect(!(a > b), false))
__builtin_trap();
return a - b;
}
donne cette assemblée
_ZN4test23positive_difference_2ndEii:
.LFB1:
.cfi_startproc
cmpl %esi, %edi
jle .L8
movl %edi, %eax
subl %esi, %eax
ret
.p2align 4,,7
.p2align 3
.L8:
ud2
.cfi_endproc
.LFE1:
avec lequel je me sens beaucoup plus à l'aise. (Exemples ont été testés avec GCC 5.3.0 en utilisant la -std=c++14
, -O3
et des -march=native
drapeaux sur 4.3.3-2-ARCH x86_64 GNU / Linux. Ne figurent pas dans les extraits ci - dessus sont les déclarations de test::positive_difference_1st
et test::positive_difference_2nd
que j'ajouté le __attribute__ ((hot))
à. my::assertion_failed
A été déclarée avec __attribute__ ((cold))
.)
Affirmer les conditions préalables dans la fonction qui en dépend
Supposons que vous ayez la fonction suivante avec le contrat spécifié.
/**
* @brief
* Counts the frequency of a letter in a string.
*
* The frequency count is case-insensitive.
*
* If `text` does not point to a NUL terminated character array or `letter`
* is not in the character range `[A-Za-z]`, the behavior is undefined.
*
* @param text
* text to count the letters in
*
* @param letter
* letter to count
*
* @returns
* occurences of `letter` in `text`
*
*/
std::size_t
count_letters(const char * text, int letter) noexcept;
Au lieu d'écrire
assert(text != nullptr);
assert((letter >= 'A' && letter <= 'Z') || (letter >= 'a' && letter <= 'z'));
const auto frequency = count_letters(text, letter);
à chaque site d'appel, mettez cette logique une fois dans la définition de count_letters
std::size_t
count_letters(const char *const text, const int letter) noexcept
{
assert(text != nullptr);
assert((letter >= 'A' && letter <= 'Z') || (letter >= 'a' && letter <= 'z'));
auto frequency = std::size_t {};
// TODO: Figure this out...
return frequency;
}
et appelez-le sans plus tarder.
const auto frequency = count_letters(text, letter);
Cela présente les avantages suivants.
- Il vous suffit d'écrire le code d'assertion une fois. Etant donné que le but même des fonctions est qu’elles soient appelées - souvent plus d’une fois - cela devrait réduire le nombre total d’
assert
instructions dans votre code.
- Il maintient la logique qui vérifie les conditions préalables à proximité de la logique qui en dépend. Je pense que c'est l'aspect le plus important. Si vos clients utilisent mal votre interface, on ne peut pas supposer qu'ils appliquent correctement les assertions. Il est donc préférable que la fonction leur indique.
L'inconvénient évident est que le message de diagnostic ne contient pas l'emplacement source du site de l'appel. Je crois que c'est un problème mineur. Un bon débogueur devrait pouvoir vous permettre de retracer facilement l'origine de la violation du contrat.
La même réflexion s’applique aux fonctions «spéciales» telles que les opérateurs surchargés. Lorsque j'écris des itérateurs, généralement, si la nature de l'itérateur le permet, je leur attribue une fonction membre.
bool
good() const noexcept;
cela permet de demander s'il est prudent de déréférencer l'itérateur. (Bien sûr, dans la pratique, il est presque toujours possible de garantir qu'il ne sera pas prudent de déréférencer l'itérateur. Mais je pense que vous pouvez toujours capturer beaucoup de bogues avec une telle fonction.) Au lieu de tout mon code qui utilise l’itérateur avec des assert(iter.good())
déclarations, je préfère mettre un simple assert(this->good())
comme première ligne de la mise operator*
en œuvre de l’itérateur.
Si vous utilisez la bibliothèque standard, au lieu d'affirmer manuellement ses conditions préalables dans votre code source, activez leurs vérifications dans les versions de débogage. Ils peuvent effectuer des vérifications encore plus sophistiquées, comme de vérifier si le conteneur référencé par un itérateur existe toujours. (Consultez la documentation de libstdc ++ et de libc ++ (travaux en cours) pour plus d'informations.)
Facteur conditions communes sur
Supposons que vous écriviez un paquet d’algèbre linéaire. De nombreuses fonctions auront des conditions préalables compliquées et leur violation entraînera souvent des résultats erronés qui ne sont pas immédiatement reconnaissables en tant que tels. Ce serait très bien si ces fonctions affirmaient leurs conditions préalables. Si vous définissez une série de prédicats qui vous indiquent certaines propriétés d'une structure, ces assertions deviennent beaucoup plus lisibles.
template <typename MatrixT>
auto
cholesky_decompose(MatrixT&& m)
{
assert(is_square(m) && is_symmetric(m));
// TODO: Somehow decompose that thing...
}
Cela donnera aussi plus de messages d'erreur utiles.
cholesky.hxx:357: cholesky_decompose: assertion failed: is_symmetric(m)
aide beaucoup plus que, disons
detail/basic_ops.hxx:1289: fast_compare: assertion failed: m(i, j) == m(j, i)
où vous devez d’abord consulter le code source dans le contexte pour déterminer ce qui a été réellement testé.
Si vous avez class
des invariants non triviaux, c'est probablement une bonne idée de les appliquer de temps en temps lorsque vous avez modifié l'état interne et que vous voulez vous assurer que l'objet est renvoyé dans un état valide.
Dans ce but, j'ai trouvé utile de définir une private
fonction membre que j'appelle de manière conventionnelle class_invaraiants_hold_
. Supposons que vous soyez en train de ré-implémenter std::vector
(parce que nous savons tous que ce n'est pas assez bon), cela pourrait avoir une fonction comme celle-ci.
template <typename T>
bool
vector<T>::class_invariants_hold_() const noexcept
{
if (this->size_ > this->capacity_)
return false;
if ((this->size_ > 0) && (this->data_ == nullptr))
return false;
if ((this->capacity_ == 0) != (this->data_ == nullptr))
return false;
return true;
}
Notez quelques petites choses à ce sujet.
- La fonction prédicat lui - même est
const
et noexcept
, conformément à la directive que les affirmations ne doivent pas avoir des effets secondaires. Si cela a du sens, déclarez-le également constexpr
.
- Le prédicat n'affirme rien en soi. Il est destiné à être appelé à l' intérieur d' assertions, telles que
assert(this->class_invariants_hold_())
. De cette façon, si les assertions sont compilées, nous pouvons être sûrs que cela ne génère pas de temps système supplémentaire.
- Le flux de contrôle à l'intérieur de la fonction est divisé en plusieurs
if
instructions avec return
s au lieu d'une expression large. Cela facilite l'exploration de la fonction dans un débogueur et la découverte de la partie de l'invariant qui a été cassée si l'assertion est déclenchée.
Ne pas affirmer des bêtises
Certaines choses n’ont tout simplement pas de sens.
auto numbers = std::vector<int> {};
numbers.push_back(14);
numbers.push_back(92);
assert(numbers.size() == 2); // silly
assert(!numbers.empty()); // silly and redundant
Ces assertions ne rendent pas le code même un tout petit peu plus lisible ou plus facile à raisonner. Chaque programmeur C ++ doit être suffisamment sûr de la manière dont il std::vector
fonctionne pour s’assurer que le code ci-dessus est correct en le regardant simplement. Je ne dis pas que vous ne devriez jamais affirmer sur la taille d'un conteneur. Si vous avez ajouté ou supprimé des éléments à l'aide d'un flux de contrôle non trivial, une telle assertion peut s'avérer utile. Mais si elle répète simplement ce qui a été écrit dans le code de non-assertion juste au-dessus, aucune valeur ne sera gagnée.
Aussi, n'affirmez pas que les fonctions de la bibliothèque fonctionnent correctement.
auto w = widget {};
w.enable_quantum_mode();
assert(w.quantum_mode_enabled()); // probably silly
Si vous ne faites que peu confiance à la bibliothèque, pensez plutôt à utiliser une autre bibliothèque.
D'autre part, si la documentation de la bibliothèque n'est pas claire à 100% et que vous prenez confiance en ses contrats en lisant le code source, il est tout à fait logique d'affirmer ce «contrat inféré». Si cela se produit dans une future version de la bibliothèque, vous le remarquerez rapidement.
auto w = widget {};
// After reading the source code, I have concluded that quantum mode is
// always off by default but this isn't documented anywhere.
assert(!w.quantum_mode_enabled());
C'est mieux que la solution suivante qui ne vous dira pas si vos hypothèses étaient correctes.
auto w = widget {};
if (w.quantum_mode_enabled())
{
// I don't think that quantum mode is ever enabled by default but
// I'm not sure.
w.disable_quantum_mode();
}
Ne pas abuser des assertions pour implémenter la logique du programme
Les assertions ne doivent être utilisées que pour découvrir les bogues dignes de tuer immédiatement votre application. Ils ne doivent pas être utilisés pour vérifier une autre condition même si la réaction appropriée à cette condition serait également de cesser immédiatement.
Par conséquent, écris ceci…
if (!server_reachable())
{
log_message("server not reachable");
shutdown();
}
…au lieu de.
assert(server_reachable());
De même, n’utilisez jamais d’assertions pour valider des entrées non fiables ou des vérifications qui std::malloc
ne return
vous ont pas été attribuées nullptr
. Même si vous savez que vous ne désactiverez jamais les assertions, même dans les versions finales, une assertion indique au lecteur qu'elle vérifie quelque chose qui est toujours vrai, car le programme ne contient pas de bogues et n'a pas d'effets secondaires visibles. Si ce n'est pas le type de message que vous souhaitez communiquer, utilisez un autre mécanisme de traitement des erreurs, tel qu'une throw
exception. Si vous trouvez pratique de disposer d'un wrapper de macro pour vos vérifications de non-assertion, continuez en écrivant un. N'appelez-le pas simplement «affirmer», «assumer», «exiger», «assurer» ou quelque chose du genre. Sa logique interne pourrait être la même que pour assert
, sauf qu'elle n'est jamais compilée, bien sûr.
Plus d'information
J'ai trouvé parler de John Lakos Programmation défensive Fait droit , donné à CppCon'14 ( 1 er partie , 2 ème partie ) très instructif. Il prend l'idée de personnaliser quelles assertions sont activées et comment réagir aux exceptions échouées encore plus loin que dans cette réponse.