Un guide définitif sur la logique conditionnelle en JavaScript

Je suis ingénieur et mathématicien front-end. Je compte quotidiennement sur ma formation mathématique pour écrire du code. Ce ne sont pas des statistiques ou des calculs que j'utilise, mais plutôt ma compréhension approfondie de la logique booléenne. Souvent, j'ai transformé une combinaison complexe d'esperluettes, de tuyaux, de points d'exclamation et de signes égaux en quelque chose de plus simple et beaucoup plus lisible. J'aimerais partager ces connaissances, j'ai donc écrit cet article. C'est long mais j'espère que cela vous sera aussi bénéfique que moi. Prendre plaisir!

Valeurs Truthy & Falsy en JavaScript

Avant d'étudier les expressions logiques, comprenons ce qui est «véridique» en JavaScript. Étant donné que JavaScript est mal typé, il force les valeurs en booléens dans les expressions logiques. ifdéclarations, &&, ||et les conditions de ternaires toutes les valeurs de COERCE dans booléens. Notez que cela ne signifie pas qu'ils renvoient toujours un booléen de l'opération.

Il n'y a que six falsy valeurs JavaScript - false, null, undefined, NaN, 0et ""- et tout le reste truthy . Cela signifie que []et {}sont tous deux véridiques, ce qui a tendance à faire trébucher les gens.

Les opérateurs logiques

En logique formelle, seuls quelques opérateurs existent: négation, conjonction, disjonction, implication et bicondition. Chacun d'eux a un équivalent JavaScript: !, &&, ||, if (/* condition */) { /* then consequence */}et ===, respectivement. Ces opérateurs créent toutes les autres instructions logiques.

Tables de vérité

Tout d'abord, regardons les tables de vérité pour chacun de nos opérateurs de base. Une table de vérité nous dit ce que la véracité d'une expression est basée sur la véracité de ses parties . Les tables de vérité sont importantes. Si deux expressions génèrent la même table de vérité, alors ces expressions sont équivalentes et peuvent se remplacer .

La table de négation est très simple. La négation est le seul opérateur logique unaire, n'agissant que sur une seule entrée. Cela signifie que ce !A || Bn'est pas la même chose que !(A || B). Les parenthèses agissent comme la notation de regroupement que vous trouverez en mathématiques.

Par exemple, la première ligne de la table de vérité de négation (ci-dessous) doit être lue comme suit: "si l'instruction A est vraie, alors l'expression! A est fausse."

Nier une simple déclaration n'est pas difficile. La négation de « il pleut » est « il ne pleut » et la négation des primitives de JavaScript trueest, bien sûr, false. Cependant, nier des déclarations ou des expressions complexes n'est pas si simple. Quelle est la négation de «il pleut toujours » ou isFoo && isBar?

La Conjonction tableau montre que l'expression A && Best vrai que si les deux A et B sont vraies. Cela devrait être très familier lors de l'écriture de JavaScript.

La table de disjonction doit également être très familière. Une disjonction (instruction OR logique) est vraie si l' un ou les deuxde A et B sont vrais.

La table d' implication n'est pas aussi familière. Puisque A implique B, A étant vrai implique que B est vrai. Cependant, B peut être vrai pour des raisons autres que A, c'est pourquoi les deux dernières lignes du tableau sont vraies. La seule implication temporelle est fausse est quand A est vrai et B est faux parce qu'alors A n'implique pas B.

Bien que les ifinstructions soient utilisées pour des implications en JavaScript, toutes les ifinstructions ne fonctionnent pas de cette façon. Habituellement, nous utilisons ifcomme un contrôle de flux, pas comme un contrôle de véracité où la conséquence compte également dans le contrôle. Voici l' énoncé d' implication archétypique if:

function implication(A, B) { if (A) { return B; } else { /* if A is false, the implication is true */ return true; }}

Ne vous inquiétez pas, c'est un peu gênant. Il existe des moyens plus simples de coder les implications. En raison de cette maladresse, cependant, je continuerai à utiliser comme symbole des implications tout au long de cet article.

L' opérateur Bicondition , parfois appelé if-and-only-if (IFF), prend la valeur true uniquement si les deux opérandes, A et B, partagent la même valeur de véracité. En raison de la façon dont JavaScript gère les comparaisons, l'utilisation de ===à des fins logiques ne doit être utilisée que sur les opérandes convertis en booléens. Autrement dit, au lieu de A === B, nous devrions utiliser !!A === !!B.

Mises en garde

Il y a deux grandes mises en garde pour traiter le code JavaScript comme une logique propositionnelle: le court-circuit et l' ordre des opérations .

Le court-circuit est quelque chose que les moteurs JavaScript font pour gagner du temps. Quelque chose qui ne changera pas la sortie de l'expression entière n'est pas évalué. La fonction doSomething()dans les exemples suivants n'est jamais appelée car, peu importe ce qu'elle a renvoyé, le résultat de l'expression logique ne changerait pas:

// doSomething() is never calledfalse && doSomething();true || doSomething();

Rappelez-vous que les conjonctions ( &&) ne sont vraies que si les deux instructions sont vraies et que disjunctions ( ||) ne sont fausses que si les deux instructions sont fausses. Dans chacun de ces cas, après lecture de la première valeur, aucun calcul supplémentaire n'est nécessaire pour évaluer le résultat logique des expressions.

En raison de cette fonctionnalité, JavaScript rompt parfois la commutativité logique. Cela A && Béquivaut logiquement à B && A, mais vous interrompriez votre programme si vous commutiez window && window.mightNotExisten window.mightNotExist && window. Cela ne veut pas dire que la véracité d'une expression commutée est différente, juste que JavaScript peut générer une erreur en essayant de l'analyser.

L'ordre des opérations en JavaScript m'a pris par surprise car on ne m'a pas appris que la logique formelle avait un ordre des opérations, autre que par regroupement et de gauche à droite. Il s'avère que de nombreux langages de programmation considèrent &&avoir une priorité plus élevée que ||. Cela signifie qu'il &&est d'abord groupé (non évalué), de gauche à droite, puis ||groupé de gauche à droite. Cela signifie que ce A || B && Cn'est pas évalué de la même manière que (A || B) && C, mais plutôt comme A || (B && C).

true || false && false; // evaluates to true(true || false) && false; // evaluates to false

Heureusement, le regroupement , (), détient la plus haute priorité en JavaScript. Nous pouvons éviter les surprises et l'ambiguïté en associant manuellement les déclarations que nous voulons évaluées ensemble dans des expressions discrètes. C'est pourquoi de nombreux linters de code interdisent d'avoir les deux &&et ||au sein du même groupe.

Calcul des tables de vérité composées

Maintenant que la véracité des déclarations simples est connue, la véracité des expressions plus complexes peut être calculée.

Pour commencer, comptez le nombre de variables dans l'expression et écrivez une table de vérité qui a 2ⁿ lignes.

Next, create a column for each of the variables and fill them with every possible combination of true/false values. I recommend filling the first half of the first column with T and the second half with F, then quartering the next column and so on until it looks like this:

Then write the expression down and solve it in layers, from the innermost groups outward for each combination of truth values:

As stated above, expressions which produce the same truth table can be substituted for each other.

Rules of replacements

Now I’ll cover several examples of rules of replacements that I often use. No truth tables are included below, but you can construct them yourself to prove that these rules are correct.

Double negation

Logically, A and !!A are equivalent. You can always remove a double negation or add a double negation to an expression without changing its truthiness. Adding a double-negation comes in handy when you want to negate part of a complex expression. The one caveat here is that in JavaScript !! also acts to coerce a value into a boolean, which may be an unwanted side-effect.

A === !!A

Commutation

Any disjunction (||), conjunction (&&), or bicondition (===) can swap the order of its parts. The following pairs are logically equivalent, but may change the program’s computation because of short-circuiting.

(A || B) === (B || A)

(A && B) === (B && A)

(A === B) === (B === A)

Association

Disjunctions and conjunctions are binary operations, meaning they only operate on two inputs. While they can be coded in longer chains — A || B || C || D — they are implicitly associated from left to right — ((A || B) || C) || D. The rule of association states that the order in which these groupings occur make no difference to the logical outcome.

((A || B) || C) === (A || (B || C))

((A && B) && C) === (A && (B && C))

Distribution

Association does not work across both conjunctions and disjunctions. That is, (A && (B || C)) !== ((A && B) || C). In order to disassociate B and C in the previous example, you must distribute the conjunction — (A && B) || (A && C). This process also works in reverse. If you find a compound expression with a repeated disjunction or conjunction, you can un-distribute it, akin to factoring out a common factor in an algebraic expression.

(A && (B || C)) === ((A && B) || (A && C))

(A || (B && C)) === ((A || B) && (A || C))

Another common occurrence of distribution is double-distribution (similar to FOIL in algebra):

1. ((A || B) && (C || D)) === ((A || B) && C) || ((A || B) && D)

2. ((A || B) && C) || ((A || B) && D) ===

((A && C) || B && C)) || ((A && D) || (B && D))

(A || B) && (C || D) === (A && C) || (B && C) || (A && D) || (B && D)

(A && B) ||(C && D) === (A || C) && (B || C) && (A || D) && (B || D)

Material Implication

Implication expressions (A → B) typically get translated into code as if (A) { B } but that is not very useful if a compound expression has several implications in it. You would end up with nested if statements — a code smell. Instead, I often use the material implication rule of replacement, which says that A → B means either A is false or B is true.

(A → B) === (!A || B)

Tautology & Contradiction

Sometimes during the course of manipulating compound logical expressions, you’ll end up with a simple conjunction or disjunction that only involves one variable and its negation or a boolean literal. In those cases, the expression is either always true (a tautology) or always false (a contradiction) and can be replaced with the boolean literal in code.

(A || !A) === true

(A || true) === true

(A && !A) === false

(A && false) === false

Related to these equivalencies are the disjunction and conjunction with the other boolean literal. These can be simplified to just the truthiness of the variable.

(A || false) === A

(A && true) === A

Transposition

When manipulating an implication (A → B), a common mistake people make is to assume that negating the first part, A, implies the second part, B, is also negated — !A → !B. This is called the converse of the implication and it is not necessarily true. That is, having the original implication does not tell us if the converse is true because A is not a necessary condition of B. (If the converse is also true — for independent reasons — then A and B are biconditional.)

What we can know from the original implication, though, is that the contrapositive is true. Since Bis a necessary condition for A (recall from the truth table for implication that if B is true, A must also be true), we can claim that !B → !A.

(A → B) === (!B → !A)

Material Equivalence

The name biconditional comes from the fact that it represents two conditional (implication) statements: A === B means that A → BandB → A. The truth values of A and B are locked into each other. This gives us the first material equivalence rule:

(A === B) === ((A → B) && (B → A))

Using material implication, double-distribution, contradiction, and commutation, we can manipulate this new expression into something easier to code:

1. ((A → B) && (B → A)) === ((!A || B) && (!B || A))

2. ((!A || B) && (!B || A)) ===

((!A && !B) || (B && !B)) || ((!A && A) || (B && A))

3. ((!A && !B) || (B && !B)) || ((!A && A) || (B && A)) ===

((!A && !B) || (B && A))

4. ((!A && !B) || (B && A)) === ((A && B) || (!A && !B))

(A === B) === ((A && B) || (!A && !B))

Exportation

Nested if statements, especially if there are no else parts, are a code smell. A simple nested if statement can be reduced into a single statement where the conditional is a conjunction of the two previous conditions:

if (A) { if (B) { C }}// is equivalent toif (A && B) { C}
(A → (B → C)) === ((A && B) → C)

DeMorgan’s Laws

DeMorgan’s Laws are essential to working with logical statements. They tell how to distribute a negation across a conjunction or disjunction. Consider the expression !(A || B). DeMorgan’s Laws say that when negating a disjunction or conjunction, negate each statement and change the && to ||or vice versa. Thus !(A || B) is the same as !A && !B. Similarly, !(A && B)is equivalent to !A || !B.

!(A || B) === !A && !B

!(A && B) === !A || !B

Ternary (If-Then-Else)

Ternary statements (A ? B : C) occur regularly in programming, but they’re not quite implications. The translation from a ternary to formal logic is actually a conjunction of two implications, A → B and !A → C, which we can write as: (!A || B) && (A || C), using material implication.

(A ? B : C) === (!A || B) && (A || C)

XOR (Exclusive Or)

Exclusive Or, often abbreviated xor, means, “one or the other, but not both.” This differs from the normal or operator only in that both values cannot be true. This is often what we mean when we use “or” in plain English. JavaScript doesn’t have a native xor operator, so how would we represent this?

1. “A or B, but not both A and B”

2. (A || B) && !(A && B)direct translation

3. (A || B) && (!A || !B)DeMorgan’s Laws

4. (!A || !B) && (A || B)commutativity

5. A ? !B : Bdéfinition if-then-else

A ? !B : B est exclusif ou (xor) en JavaScript

Alternativement,

1. «A ou B, mais pas à la fois A et B»

2. (A || B) && !(A && B)traduction directe

3. (A || B) && (!A || !B)Lois de DeMorgan

4. (A && !A) || (A && !B) || (B && !A) || (B && !B)double distribution

5. (A && !B) || (B && !A)remplacement de contradiction

6. A === !Bou A !== Béquivalence matérielle

A === !BouA !== B est xor en JavaScript

Définir la logique

Jusqu'à présent, nous avons examiné des déclarations sur des expressions impliquant deux (ou quelques) valeurs, mais nous allons maintenant tourner notre attention vers des ensembles de valeurs. Tout comme les opérateurs logiques dans les expressions composées préservent la véracité de manière prévisible, les fonctions de prédicat sur les ensembles préservent la véracité de manière prévisible.

A predicate function is a function whose input is a value from a set and whose output is a boolean. For the following code examples, I will use an array of numbers for a set and two predicate functions:isOdd = n => n % 2 !== 0; and isEven = n => n % 2 === 0;.

Universal Statements

A universal statement is one that applies to all elements in a set, meaning its predicate function returns true for every element. If the predicate returns false for any one (or more) element, then the universal statement is false. Array.prototype.every takes a predicate function and returns true only if every element of the array returns true for the predicate. It also terminates early (with false) if the predicate returns false, not running the predicate over any more elements of the array, so in practice avoid side-effects in predicates.

À titre d'exemple, considérons le tableau [2, 4, 6, 8]et la déclaration universelle, "chaque élément du tableau est pair." En utilisant isEvenet la fonction universelle intégrée de JavaScript, nous pouvons exécuter [2, 4, 6, 8].every(isEven)et constater que c'est le cas true.

Array.prototype.every est la déclaration universelle de JavaScript

Déclarations existentielles

Une déclaration existentielle fait une revendication spécifique sur un ensemble: au moins un élément de l'ensemble renvoie vrai pour la fonction de prédicat. Si le prédicat renvoie false pour chaque élément de l'ensemble, alors l'instruction existentielle est false.

JavaScript fournit également une déclaration existentielle intégrée: Array.prototype.some. Similaire à every, someretournera tôt (avec true) si un élément satisfait son prédicat. Par exemple, [1, 3, 5].some(isOdd)n'exécutera qu'une seule itération du prédicat isOdd(consommant 1et retournant true) et retournera true. [1, 3, 5].some(isEven)reviendra false.

Array.prototype.some est la déclaration existentielle de JavaScript

Implication universelle

Une fois que vous avez vérifié une déclaration universelle par rapport à un ensemble, disons nums.every(isOdd), il est tentant de penser que vous pouvez saisir un élément de l'ensemble qui satisfait le prédicat. Cependant, il y a un hic: en logique booléenne, une véritable déclaration universelle n'implique pas que l'ensemble n'est pas vide. Les déclarations universelles sur les ensembles vides sont toujours vraies , donc si vous souhaitez récupérer un élément d'un ensemble satisfaisant une condition, utilisez plutôt une vérification existentielle. Pour le prouver, exécutez [].every(() => false). Ce sera vrai.

Les déclarations universelles sur les ensembles vides sont toujours vraies .

Négation des déclarations universelles et existentielles

Nier ces affirmations peut être surprenant. La négation d'une déclaration universelle, disons nums.every(isOdd), n'est pas nums.every(isEven), mais plutôt nums.some(isEven). Il s'agit d'une déclaration existentielle avec le prédicat annulé. De même, la négation d'une déclaration existentielle est une déclaration universelle avec le prédicat nié.

!arr.every(el => fn(el)) === arr.some(el => !fn(el))

!arr.some(el => fn(el)) === arr.every(el => ! fn (el))

Définir les intersections

Deux ensembles ne peuvent être liés l'un à l'autre que de quelques façons, en ce qui concerne leurs éléments. Ces relations sont facilement schématisées avec les diagrammes de Venn et peuvent (principalement) être déterminées dans le code en utilisant des combinaisons d'énoncés universels et existentiels.

Deux ensembles peuvent chacun partager certains de leurs éléments, mais pas tous, comme un diagramme de Venn conjoint typique :

A.some(el => B.includes(el)) && A.some(el => !B.includes(el)) && B.some(el => !A.includes (el)) décrit une paire d'ensembles conjoints

Un ensemble peut contenir tous les éléments de l'autre ensemble, mais avoir des éléments non partagés par le second ensemble. Il s'agit d'une relation de sous - ensemble , désignée par Subset ⊆ Superset.

B.every(el => A.includes(el)) décrit la relation de sous-ensemble B ⊆ A

Les deux ensembles ne peuvent partager aucun élément. Ce sont des ensembles disjoints .

A.every(el => !B.includes(el)) décrit une paire d'ensembles disjoints

Enfin, les deux ensembles peuvent partager chaque élément. Autrement dit, ils sont des sous-ensembles les uns des autres. Ces ensembles sont égaux . Dans la logique formelle, nous écririons A ⊆ B && B ⊆ A ⟷ A === B, mais en JavaScript, il y a quelques complications à cela. En JavaScript, an Arrayest un ensemble ordonné et peut contenir des valeurs en double, nous ne pouvons donc pas supposer que le code de sous-ensemble bidirectionnel B.every(el => A.includes(el)) && A.every(el => B.includes (el)) implique les rrayons a Aet B sont equa l. Si Aet B sont des ensembles (ce qui signifie qu'ils ont été créés with newSet ()), alors leurs valeurs sont uniques et nous pouvons faire la vérification du sous-ensemble bidirectionnel à s ee if A=== B.

(A === B) === (Array.from(A).every(el => Array.from(B).includes(el)) && Array.from(B).every(el => Array.from(A).includes (el)), étant donné que Aet Bare construit using newSet ()

Traduire Logic en anglais

This section is probably the most useful in the article. Here, now that you know the logical operators, their truth tables, and rules of replacement, you can learn how to translate an English phrase into code and simplify it. In learning this translation skill, you will also be able to read code better, storing complex logic in simple phrases in your mind.

Below is a table of logical code (left) and their English equivalents (right) that was heavily borrowed from the excellent book, Essentials of Logic.

Below, I will go through some real-world examples from my own work where I interpret from English to code, and vice-versa, and simplify code with the rules of replacement.

Example 1

Recently, to satisfy the EU’s GDPR requirements, I had to create a modal that showed my company’s cookie policy and allowed the user to set their preferences. To make this as unobtrusive as possible, we had the following requirements (in order of precedence):

  1. If the user wasn’t in the EU, never show the GDPR preferences modal.
  2. 2. If the app programmatically needs to show the modal (if a user action requires more permission than currently allowed), show the modal.
  3. If the user is allowed to have the less-obtrusive GDPR banner, do not show the modal.
  4. If the user has not already set their preferences (ironically saved in a cookie), show the modal.

I started off with a series of if statements modeled directly after these requirements:

const isGdprPreferencesModalOpen = ({ shouldModalBeOpen, hasCookie, hasGdprBanner, needsPermissions}) => { if (!needsPermissions) { return false; } if (shouldModalBeOpen) { return true; } if (hasGdprBanner) { return false; } if (!hasCookie) { return true; } return false;}

To be clear, the above code works, but returning boolean literals is a code smell. So I went through the following steps:

/* change to a single return, if-else-if structure */let result;if (!needsPermissions) { result = false;} else if (shouldBeOpen) { result = true;} else if (hasBanner) { result = false;} else if (!hasCookie) { result = true} else { result = false;}return result;
/* use the definition of ternary to convert to a single return */return !needsPermissions ? false : (shouldBeOpen ? true : (hasBanner ? false : (!hasCookie ? true : false)))
/* convert from ternaries to conjunctions of disjunctions */return (!!needsPermissions || false) && (!needsPermissions || ((!shouldBeOpen || true) && (shouldBeOpen || ((!hasBanner || false) && (hasBanner || !hasCookie))))
/* simplify double-negations and conjunctions/disjunctions with boolean literals */return needsPermissions && (!needsPermissions || ((!shouldBeOpen || true) && (shouldBeOpen || (!hasBanner && (hasBanner || !hasCookie))))
/* DeMorgan's Laws */return needsPermissions && (!needsPermissions || ((!shouldBeOpen || true) && (shouldBeOpen || ((!hasBanner && hasBanner) || (hasBanner && !hasCookie))))
/* eliminate tautologies and contradictions, simplify */return needsPermissions && (!needsPermissions || (shouldBeOpen || (hasBanner && !hasCookie)))
/* DeMorgan's Laws */return (needsPermissions && !needsPermissions) || (needsPermissions && (shouldBeOpen || (hasBanner && !hasCookie)))
/* eliminate contradiction, simplify */return needsPermissions && (shouldBeOpen || (hasBanner && !hasCookie))

I ended up with something that I think is more elegant and still readable:

const isGdprPreferencesModalOpen = ({ needsPermissions, shouldBeOpen, hasBanner, hasCookie,}) => ( needsPermissions && (shouldBeOpen || (!hasBanner && !hasCookie)));

Example 2

I found the following code (written by a coworker) while updating a component. Again, I felt the urge to eliminate the boolean literal returns, so I refactored it.

const isButtonDisabled = (isRequestInFlight, state) => { if (isRequestInFlight) { return true; } if (enabledStates.includes(state)) { return false; } return true;};

Sometimes I do the following steps in my head or on scratch paper, but most often, I write each next step in the code and then delete the previous step.

// convert to if-else-if structurelet result;if (isRequestInFlight) { result = true;} else if (enabledStates.includes(state)) { result = false;} else { result = true;}return result;
// convert to ternaryreturn isRequestInFlight ? true : enabledStates.includes(state) ? false : true;
/* convert from ternary to conjunction of disjunctions */return (!isRequestInFlight || true) && (isRequestInFlight || ((!enabledStates.includes(state) || false) && (enabledStates.includes(state) || true))
/* remove tautologies and contradictions, simplify */return isRequestInFlight || !enabledStates.includes(state)

Then I end up with:

const isButtonDisabled = (isRequestInFlight, state) => ( isRequestInFlight || !enabledStates.includes(state));

In this example, I didn’t start with English phrases and I never bothered to interpret the code to English while doing the manipulations, but now, at the end, I can easily translate this: “the button is disabled if either the request is in flight or the state is not in the set of enabled states.” That makes sense. If you ever translate your work back to English and it doesn’t make sense, re-check your work. This happens to me often.

Example 3

While writing an A/B testing framework for my company, we had two master lists of Enabled and Disabled experiments and we wanted to check that every experiment (each a separate file in a folder) was recorded in one or the other list but not both. This means the enabled and disabled sets are disjointed and the set of all experiments is a subset of the conjunction of the two sets of experiments. The reason the set of all experiments must be a subset of the combination of the two lists is that there should not be a single experiment that exists outside the two lists.

const isDisjoint = !enabled.some(el => disabled.includes(el)) && !disabled.some(el => enabled.includes(el));const isSubset = allExperiments.every( el => enabled.concat(disabled).includes(el));assert(isDisjoint && isSubset);

Conclusion

Hopefully this has all been helpful. Not only are the skills of translating between English and code useful, but having the terminology to discuss different relationships (like conjunctions and implications) and the tools to evaluate them (truth tables) is handy.