Una guía definitiva para la lógica condicional en JavaScript

Soy ingeniero de front-end y matemático. Confío en mi entrenamiento matemático a diario para escribir código. No utilizo estadísticas ni cálculo, sino más bien mi conocimiento profundo de la lógica booleana. A menudo, he convertido una combinación compleja de símbolos de unión, tuberías, signos de exclamación y signos de igual en algo más simple y mucho más legible. Me gustaría compartir este conocimiento, así que escribí este artículo. Es largo, pero espero que sea tan beneficioso para ti como lo ha sido para mí. ¡Disfrutar!

Valores de verdad y falsedad en JavaScript

Antes de estudiar las expresiones lógicas, comprendamos qué es "veraz" en JavaScript. Dado que JavaScript está escrito de manera flexible, convierte los valores en booleanos en expresiones lógicas. ifestados, &&, ||, y condiciones ternarios todos los valores de obligarle a booleanos. Tenga en cuenta que esto no significa que siempre devuelvan un valor booleano de la operación.

Sólo hay seis Falsy valores en JavaScript - false, null, undefined, NaN, 0, y ""- y todo lo demás es Truthy . Esto significa que []y {}ambos son veraces, lo que tiende a hacer tropezar a la gente.

Los operadores lógicos

En lógica formal, existen solo unos pocos operadores: negación, conjunción, disyunción, implicación y bicondición. Cada uno de estos tiene un equivalente de JavaScript: !, &&, ||, if (/* condition */) { /* then consequence */}, y ===, respectivamente. Estos operadores crean todas las demás declaraciones lógicas.

Tablas de la verdad

Primero, veamos las tablas de verdad para cada uno de nuestros operadores básicos. Una tabla de verdad nos dice cuál es la veracidad de una expresión basada en la veracidad de sus partes . Las tablas de la verdad son importantes. Si dos expresiones generan la misma tabla de verdad, esas expresiones son equivalentes y pueden reemplazarse entre sí .

La tabla de negación es muy sencilla. La negación es el único operador lógico unario, que actúa solo sobre una única entrada. Esto significa que !A || Bno es lo mismo que !(A || B). Los paréntesis actúan como la notación de agrupación que encontraría en matemáticas.

Por ejemplo, la primera fila de la tabla de verdad de negación (a continuación) debe leerse así: "si la declaración A es verdadera, entonces la expresión! A es falsa"

Negar una declaración simple no es difícil. La negación de la “está lloviendo” es “se no llueve,” y la negación de primitivas de JavaScript truees, por supuesto, false. Sin embargo, negar declaraciones o expresiones complejas no es tan simple. ¿Cuál es la negación de " siempre está lloviendo" o isFoo && isBar?

La tabla de Conjunción muestra que la expresión A && Bes verdadera solo si tanto A como B son verdaderas. Esto debería resultarle muy familiar al escribir JavaScript.

La tabla Disyunción también debería resultarle muy familiar. Una disyunción (enunciado OR lógico) es verdadera si una o ambasde A y B son verdaderas.

La tabla de implicaciones no es tan familiar. Dado que A implica B, que A sea verdadero implica que B es verdadero. Sin embargo, B puede ser cierto por razones distintas a A, razón por la cual las dos últimas líneas de la tabla son verdaderas. La única vez que la implicación es falsa es cuando A es verdadera y B es falsa porque entonces A no implica B.

Si bien las ifdeclaraciones se utilizan para implicaciones en JavaScript, no todas las ifdeclaraciones funcionan de esta manera. Por lo general, lo usamos ifcomo un control de flujo, no como un control de veracidad donde la consecuencia también es importante en el control. Aquí está la declaración de implicación arquetípica if:

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

No se preocupe, esto es algo incómodo. Hay formas más fáciles de codificar las implicaciones. Sin embargo, debido a esta incomodidad, continuaré usándolo como símbolo de las implicaciones a lo largo de este artículo.

El operador Bicondition , a veces llamado if-and-only-if (IFF), se evalúa como verdadero solo si los dos operandos, A y B, comparten el mismo valor de veracidad. Debido a cómo JavaScript maneja las comparaciones, el uso de ===con fines lógicos solo debe usarse en operandos convertidos a booleanos. Es decir, en lugar de A === B, deberíamos usar !!A === !!B.

Advertencias

Hay dos grandes salvedades para tratar el código JavaScript como lógica proposicional: cortocircuito y orden de operaciones .

El cortocircuito es algo que hacen los motores JavaScript para ahorrar tiempo. Algo que no cambiará la salida de toda la expresión no se evalúa. La función doSomething()en los siguientes ejemplos nunca se llama porque, sin importar lo que devolviera, el resultado de la expresión lógica no cambiaría:

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

Recuerde que las conjunciones ( &&) son verdaderas solo si ambas declaraciones son verdaderas , y las disyunciones ( ||) son falsas solo si ambas declaraciones son falsas. En cada uno de estos casos, después de leer el primer valor, no es necesario realizar más cálculos para evaluar el resultado lógico de las expresiones.

Debido a esta característica, JavaScript a veces rompe la conmutatividad lógica. Lógicamente A && Bes equivalente a B && A, pero interrumpiría su programa si conmutara window && window.mightNotExista window.mightNotExist && window. Eso no quiere decir que la veracidad de una expresión conmutada sea diferente, solo que JavaScript puede arrojar un error al intentar analizarla.

El orden de las operaciones en JavaScript me tomó por sorpresa porque no me enseñaron que la lógica formal tenía un orden de operaciones que no fuera agrupamiento y de izquierda a derecha. Resulta que muchos lenguajes de programación consideran &&que tienen mayor precedencia que ||. Esto significa que &&se agrupa (no se evalúa) primero, de izquierda a derecha, y luego ||se agrupa de izquierda a derecha. Esto significa que A || B && Cse no se evaluaron de la misma manera como (A || B) && C, sino más bien como A || (B && C).

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

Afortunadamente, agrupar , ()tiene la mayor prioridad en JavaScript. Podemos evitar sorpresas y ambigüedades asociando manualmente las declaraciones que queremos evaluar juntas en expresiones discretas. Es por eso que muchos linters de código prohíben tener ambos &&y ||dentro del mismo grupo.

Calcular tablas de verdad compuestas

Ahora que se conoce la veracidad de declaraciones simples, se puede calcular la veracidad de expresiones más complejas.

Para comenzar, cuente el número de variables en la expresión y escriba una tabla de verdad que tenga 2ⁿ filas.

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 : BDefinición de si-entonces-si no

A ? !B : B es exclusivo o (xor) en JavaScript

Alternativamente,

1. "A o B, pero no tanto A como B"

2. (A || B) && !(A && B)traducción directa

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

4. (A && !A) || (A && !B) || (B && !A) || (B && !B)doble distribución

5. (A && !B) || (B && !A)reemplazo de contradicciones

6. A === !Bo A !== Bequivalencia material

A === !B oA !== B es xor en JavaScript

Establecer lógica

Hasta ahora hemos estado mirando declaraciones sobre expresiones que involucran dos (o algunos) valores, pero ahora centraremos nuestra atención en conjuntos de valores. Al igual que los operadores lógicos en expresiones compuestas preservan la veracidad de formas predecibles, las funciones de predicado en conjuntos preservan la veracidad de formas predecibles.

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.

Como ejemplo, considere la matriz [2, 4, 6, 8]y la declaración universal, "todos los elementos de la matriz son pares". Usando isEvenla función universal incorporada de JavaScript, podemos ejecutar [2, 4, 6, 8].every(isEven)y encontrar que esto es true.

Array.prototype.every es la declaración universal de JavaScript

Declaraciones existenciales

Una declaración existencial hace una afirmación específica sobre un conjunto: al menos un elemento en el conjunto devuelve verdadero para la función de predicado. Si el predicado devuelve falso para cada elemento del conjunto, entonces la declaración existencial es falsa.

JavaScript también suministra una declaración existencial incorporada: Array.prototype.some. Similar a every, someregresará antes (con verdadero) si un elemento satisface su predicado. Como ejemplo, [1, 3, 5].some(isOdd)solo ejecutará una iteración del predicado isOdd(consumir 1y devolver true) y volverá true. [1, 3, 5].some(isEven)volverá false.

Array.prototype.some es la declaración existencial de JavaScript

Implicación universal

Una vez que haya comparado una declaración universal con un conjunto, por ejemplo nums.every(isOdd), es tentador pensar que puede tomar un elemento del conjunto que satisfaga el predicado. Sin embargo, hay un problema: en la lógica booleana, una declaración universal verdadera no implica que el conjunto no esté vacío. Los enunciados universales sobre conjuntos vacíos siempre son verdaderos , por lo que si desea tomar un elemento de un conjunto que satisfaga alguna condición, utilice una verificación existencial en su lugar. Para probar esto, ejecute [].every(() => false). Será verdad.

Los enunciados universales sobre conjuntos vacíos son siempre ciertos .

Negar declaraciones universales y existenciales

Negar estas afirmaciones puede resultar sorprendente. La negación de un enunciado universal, digamos nums.every(isOdd), no es nums.every(isEven), sino más bien nums.some(isEven). Esta es una declaración existencial con el predicado negado. De manera similar, la negación de un enunciado existencial es un enunciado universal con el predicado negado.

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

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

Establecer intersecciones

Dos conjuntos solo pueden relacionarse entre sí de algunas formas, con respecto a sus elementos. Estas relaciones se diagraman fácilmente con los diagramas de Venn y pueden (en su mayoría) determinarse en código utilizando combinaciones de enunciados universales y existenciales.

Dos conjuntos pueden compartir algunos de sus elementos, pero no todos, como un diagrama de Venn combinado típico :

A.some(el => B.includes(el)) && A.some(el => !B.includes(el)) && B.some(el => !A.includes (el)) describe un par conjunto de conjuntos

Un conjunto puede contener todos los elementos del otro conjunto, pero tener elementos no compartidos por el segundo conjunto. Ésta es una relación de subconjunto , denotada como Subset ⊆ Superset.

B.every(el => A.includes(el)) describe la relación de subconjunto B ⊆ A

Los dos conjuntos pueden compartir no hay elementos. Estos son conjuntos inconexos .

A.every(el => !B.includes(el)) describe un par de conjuntos disjuntos

Por último, los dos conjuntos pueden compartir todos los elementos. Es decir, son subconjuntos unos de otros. Estos conjuntos son iguales . En lógica formal, escribiríamos A ⊆ B && B ⊆ A ⟷ A === B, pero en JavaScript, hay algunas complicaciones con esto. En JavaScript, an Arrayes un conjunto ordenado y puede contener valores duplicados, por lo que no podemos suponer que el código de subconjunto bidireccional B.every(el => A.includes(el)) && A.every(el => B.includes (el)) implica que los rrayos a Ay B son iguales l. Si Ay B son Conjuntos (lo que significa que fueron creados with newConjunto ()), entonces sus valores son únicos y podemos hacer la verificación del subconjunto bidireccional para 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)), dado que Ay el using newconjunto construido desnudo ()

Traduciendo lógica al inglés

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.