Apuntes · Capítulo 4Programación en C

Decisiones y lógica

Decisiones y lógica

Hasta ahora todos nuestros programas hacen lo mismo cada vez que corren: leen datos, calculan y escriben, siempre en el mismo orden. La fórmula de Celsius a Fahrenheit de la semana 2 no necesita más. Pero casi ningún problema real es así. Un programa que recibe una nota tiene que decir cosas distintas según la nota. Un cajero no entrega dinero si el saldo no alcanza.

En este capítulo los programas aprenden a elegir. Primero lo pensamos en pseudocódigo y en diagramas de flujo. Después lo escribimos en C con if, else y switch. Luego combinamos condiciones con &&, || y !, y vemos sus reglas: las tablas de verdad, el cortocircuito y las leyes de De Morgan. Al final bajamos un nivel: esas mismas operaciones lógicas existen como circuitos, las compuertas, y con ellas se construye la suma binaria que hicieron a mano la semana pasada.

También cerramos una deuda. Desde la semana 2 sabemos que scanf puede fallar, pero no teníamos cómo reaccionar. Desde hoy, todo programa revisa lo que devuelve scanf.

Lectura. El código de este capítulo está en code/week-04. Dentro del entorno del curso, entren a esa carpeta y corran make para compilar los ejemplos y make test para comprobarlos. Los comandos de este capítulo se corren desde ahí. Los programas de errors/ tienen errores a propósito y se compilan a mano, como se muestra en cada caso.

Ruta de lectura.

  • Antes de clase (lectura obligatoria, 15 a 20 minutos): la introducción, “Elegir un camino” y “Comparar: los operadores relacionales”. Es lo mínimo para seguir la clase.
  • En clase: “if en C”, “Varias opciones: else if”, “Decisiones anidadas”, “switch: elegir según un valor” y “Lógica booleana”.
  • Después de clase: para el laboratorio, “El laboratorio, paso a paso” y “La Práctica 1”; para repasar, “Resumen” y “Ejercicios”. Opcional: ✂️ “Compuertas: la lógica en hardware”, si no se llegó a ver en clase.

Elegir un camino

Pensemos en una tienda con esta regla: si una compra llega a 100 soles, tiene 10 % de descuento. Como aprendimos la semana pasada, el dinero se guarda en céntimos, así que 100 soles son 10000 céntimos. En pseudocódigo:

algoritmo precio_con_descuento()
    leer total
    si total ≥ 10000 entonces
        total ← total - total div 10
    fin si
    escribir total
fin algoritmo

La línea si total ≥ 10000 entonces contiene una condición: una pregunta cuya respuesta solo puede ser cierta o falsa. Si es cierta, se ejecuta lo que está entre si y fin si. Si es falsa, se salta ese bloque. La sangría de 4 espacios muestra qué pasos dependen de la condición. Y div es el cociente entero, como vimos la semana pasada. Ya usaron si en la semana 1, con mayor_de_dos; ahora lo veremos con detalle.

Sigamos el algoritmo a mano con dos compras.

Paso total con compra de S/ 120.50 total con compra de S/ 80.00
leer total 12050 8000
¿total ≥ 10000? sí no
total ← total - total div 10 12050 - 1205 = 10845 no se ejecuta
escribir total 10845 8000

Cuando hay que hacer una cosa u otra, se agrega sino. Solo se ejecuta una de las dos ramas, nunca ambas:

algoritmo par_o_impar()
    leer n
    si n mod 2 = 0 entonces
        escribir "par"
    sino
        escribir "impar"
    fin si
fin algoritmo

Recuerden que n mod 2 es el resto de dividir n entre 2, y que = es una comparación, no una asignación. Los símbolos de comparación son =, ≠, <, ≤, > y ≥.

En un diagrama de flujo

En la semana 1 vieron las formas de un diagrama de flujo. La decisión es el rombo: tiene una pregunta adentro y dos flechas de salida, sí y no.

Este es el diagrama de par_o_impar, junto al de precio_con_descuento, que no tiene sino.

Dos diagramas de flujo. A la izquierda, par_o_impar: del óvalo Inicio se pasa al paralelogramo leer n y al rombo n mod 2 = 0. La flecha sí llega al paralelogramo escribir par y la flecha no al paralelogramo escribir impar, y las dos se juntan antes del óvalo Fin. A la derecha, precio_con_descuento: de Inicio se pasa a leer total y al rombo total ≥ 10000. La flecha sí llega al rectángulo total ← total - total div 10. La flecha no, en rojo, rodea ese rectángulo y llega directo al punto donde se juntan, antes de escribir total y FinDos diagramas de flujo. A la izquierda, par_o_impar: del óvalo Inicio se pasa al paralelogramo leer n y al rombo n mod 2 = 0. La flecha sí llega al paralelogramo escribir par y la flecha no al paralelogramo escribir impar, y las dos se juntan antes del óvalo Fin. A la derecha, precio_con_descuento: de Inicio se pasa a leer total y al rombo total ≥ 10000. La flecha sí llega al rectángulo total ← total - total div 10. La flecha no, en rojo, rodea ese rectángulo y llega directo al punto donde se juntan, antes de escribir total y Fin
El rombo abre el camino y más abajo se vuelve a cerrar. Sin sino, la flecha del no solo rodea el bloque.

Fíjense en esa forma: el camino se abre en el rombo y se vuelve a cerrar más abajo. Eso es exactamente lo que marcan si y fin si. Sin sino, una de las dos flechas va directo al punto donde se juntan.

Idea clave. Una decisión divide el programa en caminos. En cada ejecución se recorre uno solo. Para probar un programa con decisiones hay que probar cada camino, no solo uno.

Comparar: los operadores relacionales

Las condiciones se escriben con operadores relacionales, que comparan dos valores.

Pseudocódigo C Significa
= == igual a
≠ != distinto de
< < menor que
≤ <= menor o igual que
> > mayor que
≥ >= mayor o igual que

En C, una comparación es una expresión como 2 + 3: se calcula y da un valor. Ese valor es un int: 1 si es cierta y 0 si es falsa. Se puede imprimir:

printf("a > b  vale %d\n", a > b);

Con a = 5 y b = 3, el programa relational.c imprime seis comparaciones:

$ build/relational
a > b  vale 1
a < b  vale 0
a == b vale 0
a != b vale 1
a >= 5 vale 1
a <= 4 vale 0

Y al revés: donde C espera una condición, acepta cualquier número. El 0 significa falso y cualquier otro número significa verdadero, también los negativos. El operador ! es la negación: !n vale 1 si n es 0, y vale 0 en cualquier otro caso.

if (n) {
    printf("%d cuenta como verdadero\n", n);
} else {
    printf("%d cuenta como falso\n", n);
}
printf("!n vale %d\n", !n);

Para probar estos programas sin teclear, usamos echo y la barra |, como la semana pasada: el texto llega al programa como si se hubiera escrito con el teclado.

$ echo -7 | build/truthy
-7 cuenta como verdadero
!n vale 0
$ echo 0 | build/truthy
0 cuenta como falso
!n vale 1

Las comparaciones se pueden hacer entre int, entre double y entre char. Como un char es un número pequeño, 'a' < 'b' es cierto: compara los códigos ASCII, 97 y 98.

En C. Hay un tipo bool si se incluye <stdbool.h>, con los valores true y false. Es cómodo, pero en este curso basta con int: las comparaciones de C dan int de todas formas.

Comparar double: lo que ya sabemos

La semana pasada vieron que 0.1 + 0.2 no da exactamente 0.3. Por eso, == entre dos double calculados casi siempre es una mala pregunta. Lo correcto es preguntar si están muy cerca: si el valor absoluto de la diferencia es menor que una tolerancia. El valor absoluto lo da fabs, de math.h. El programa compare_double.c hace las dos preguntas; esta es la segunda:

double sum = 0.1 + 0.2;

if (fabs(sum - 0.3) < 0.000001) {
    printf("Con tolerancia: iguales\n");
} else {
    printf("Con tolerancia: distintos\n");
}
$ build/compare_double
Con ==: distintos
Con tolerancia: iguales

La tolerancia depende del problema. 0.000001 sirve aquí, pero no es una regla universal.

if en C

Este es el algoritmo del descuento en C, en discount.c.

#include <stdio.h>

// Lee el total de una compra en céntimos. Desde 100 soles se descuenta el 10 %.
int main(void) {
    int total;

    if (scanf("%d", &total) != 1) {
        printf("Entrada inválida\n");
        return 1;
    }

    if (total < 0) {
        printf("El total no puede ser negativo\n");
        return 1;
    }

    if (total >= 10000) {
        total = total - total / 10;
    }

    printf("A pagar: %d.%02d soles\n", total / 100, total % 100);
    return 0;
}

La forma es if (condición) { ... }. Los paréntesis son obligatorios. Lo que está entre llaves se llama bloque y se ejecuta solo si la condición es verdadera. Hay tres if en este programa, y cada uno enseña algo.

El último if es la regla del descuento, igual que en el pseudocódigo.

El primer if revisa la lectura. scanf devuelve cuántos datos logró leer. Aquí pedimos uno, así que todo salió bien solo si devuelve 1. Si el usuario escribe abc, devuelve 0. Si la entrada se acaba sin datos, devuelve un valor negativo. En los dos casos != 1 es cierto y el programa avisa, en vez de seguir con una variable que no tiene valor.

return 1 termina el programa de inmediato. El número que devuelve main es su estado de salida, el que vieron en la semana 1 con echo $?: 0 significa que todo salió bien y otro valor significa que algo falló. Por ahora no sabemos pedir el dato de nuevo, porque para eso hace falta un ciclo, que llega la próxima semana. Hoy, ante una entrada mala, el programa avisa y termina.

El segundo if rechaza un dato que sí es un número, pero que no tiene sentido. Son dos problemas distintos: que no se pueda leer y que lo leído no sea válido.

El formato %02d escribe el entero con al menos 2 cifras, rellenando con ceros. Sin él, 5 céntimos saldrían como .5 en lugar de .05.

En el último comando, ; ejecuta una orden después de la otra, así echo $? muestra el código con el que terminó el programa.

$ echo 12050 | build/discount
A pagar: 108.45 soles
$ echo 9999 | build/discount
A pagar: 99.99 soles
$ echo 10000 | build/discount
A pagar: 90.00 soles
$ echo abc | build/discount; echo $?
Entrada inválida
1

Fíjense en los datos elegidos. 9999 y 10000 son los valores justo a cada lado del límite. Ahí es donde suelen estar los errores: si alguien escribiera > en lugar de >=, la compra de 10000 no tendría descuento.

Idea clave. Prueben siempre los bordes: el último valor de un lado de la condición y el primero del otro. Agreguen también los datos inválidos.

Error común. Revisar scanf no atrapa todos los problemas. %d lee mientras haya cifras y deja el resto sin leer, así que 15.5 se lee como 15 y 12abc como 12, y scanf devuelve 1 en ambos casos. Tampoco avisa si el número no cabe en un int. Esa situación es comportamiento indefinido en el estándar de C, y lo que se ve abajo en la última línea depende del sistema. Para esta parte del curso, la revisión de scanf es suficiente; más adelante se aprende a leer con más cuidado.

$ echo 15.5 | build/parity
15 es impar
$ echo 12abc | build/parity
12 es par
$ echo 99999999999 | build/parity
1215752191 es impar

if y else

else da el otro camino. Este es par_o_impar en parity.c:

if (n % 2 == 0) {
    printf("%d es par\n", n);
} else {
    printf("%d es impar\n", n);
}
$ echo 7 | build/parity
7 es impar
$ echo -4 | build/parity
-4 es par

Error común. Preguntar si un número es impar con n % 2 == 1. En C, el resto lleva el signo del dividendo: -3 % 2 vale -1, no 1. Con negativos, esa condición falla:

$ gcc -Wall -Wextra -std=c17 errors/parity_wrong.c -o build/parity_wrong
$ echo -3 | build/parity_wrong
-3 es par

gcc no avisa nada, porque el programa es válido: solo está mal pensado. Pregunten n % 2 == 0 para par, y n % 2 != 0 para impar.

El error clásico: = en lugar de ==

= asigna y == compara. Confundirlos es probablemente el error más famoso de C, porque x = 0 también es una expresión válida: guarda 0 en x, y su valor es el valor asignado, 0.

int x = 5;

if (x = 0) {
    printf("cero\n");
} else {
    printf("otro\n");
}
printf("x vale %d\n", x);

Sigámoslo paso a paso. x empieza en 5. La condición x = 0 guarda 0 en x y vale 0. Como 0 es falso, se ejecuta el else. El programa escribe otro, y además le cambió el valor a x. La condición no preguntó nada. Con -Wall, gcc lo detecta:

$ gcc -Wall -Wextra -std=c17 errors/assign_in_if.c -o build/assign_in_if
errors/assign_in_if.c: In function ‘main’:
errors/assign_in_if.c:7:9: warning: suggest parentheses around assignment used as truth value [-Wparentheses]
    7 |     if (x = 0) {
      |         ^
$ build/assign_in_if
otro
x vale 0

Es solo una advertencia (warning), así que el ejecutable se genera igual. Por eso la regla del curso es que el código compile sin advertencias: casi siempre señalan un error real como este.

Las llaves importan

Si un if no lleva llaves, gobierna una sola instrucción. La sangría no significa nada para el compilador; solo la leemos las personas. Miren este programa:

if (grade < 0)
    printf("Nota negativa\n");
    printf("Rechazada\n");

Parece que las dos líneas dependen del if, pero la segunda se ejecuta siempre:

$ gcc -Wall -Wextra -std=c17 errors/misleading_indent.c -o build/misleading_indent
errors/misleading_indent.c: In function ‘main’:
errors/misleading_indent.c:12:5: warning: this ‘if’ clause does not guard... [-Wmisleading-indentation]
   12 |     if (grade < 0)
      |     ^~
errors/misleading_indent.c:14:9: note: ...this statement, but the latter is misleadingly indented as if it were guarded by the ‘if’
   14 |         printf("Rechazada\n");
      |         ^~~~~~
$ echo 15 | build/misleading_indent
Rechazada

Un ; de más hace algo parecido. if (n > 0); significa “si n es positivo, no hagas nada”. La instrucción de abajo ya no depende de la condición:

$ gcc -Wall -Wextra -std=c17 errors/semicolon_if.c -o build/semicolon_if
errors/semicolon_if.c: In function ‘main’:
errors/semicolon_if.c:12:15: warning: suggest braces around empty body in an ‘if’ statement [-Wempty-body]
   12 |     if (n > 0);
      |               ^
$ echo -3 | build/semicolon_if
-3 es positivo

En este curso, todo if y todo else llevan llaves, aunque tengan una sola línea.

Error común. Una variable declarada dentro de un bloque solo existe dentro de ese bloque. Al cerrar la llave, deja de existir. Si la necesitan después, declárenla antes del if.

$ gcc -Wall -Wextra -std=c17 errors/block_scope.c -o build/block_scope
errors/block_scope.c: In function ‘main’:
errors/block_scope.c:11:20: error: ‘twice’ undeclared (first use in this function)
   11 |     printf("%d\n", twice);
      |                    ^~~~~
errors/block_scope.c:11:20: note: each undeclared identifier is reported only once for each function it appears in

Y un cuidado más. Si una variable recibe su valor dentro de un if, tiene que recibir valor en todos los caminos que luego la usan. Leer una variable sin valor es el error que vieron en la semana 2, y gcc no siempre lo detecta cuando el camino es condicional.

Varias opciones: else if

A veces hay más de dos caminos. Imaginemos una escala, inventada para este ejemplo, que da una mención según el promedio. En pseudocódigo se encadenan con sino si, y toda la cadena termina con un solo fin si:

algoritmo mencion()
    leer promedio
    si promedio < 0 o promedio > 20 entonces
        escribir "Promedio fuera de rango"
    sino si promedio ≥ 17 entonces
        escribir "Excelente"
    sino si promedio ≥ 14 entonces
        escribir "Bueno"
    sino si promedio ≥ 11 entonces
        escribir "Regular"
    sino
        escribir "Insuficiente"
    fin si
fin algoritmo

En C, en mention.c:

if (average < 0 || average > 20) {
    printf("Promedio fuera de rango\n");
} else if (average >= 17) {
    printf("Excelente\n");
} else if (average >= 14) {
    printf("Bueno\n");
} else if (average >= 11) {
    printf("Regular\n");
} else {
    printf("Insuficiente\n");
}

El símbolo || significa “o”; lo vemos con detalle más abajo. Las condiciones se prueban en orden, de arriba hacia abajo. La primera que es verdadera ejecuta su bloque y se salta todo el resto de la cadena. El else final atrapa lo que queda.

Por eso average >= 14 no necesita decir “y menor que 17”: si el programa llegó a esa línea, ya sabemos que la condición anterior fue falsa. Sigamos el promedio 15.

Condición ¿Se cumple con 15? Qué pasa
average < 0 || average > 20 no sigue
average >= 17 no sigue
average >= 14 sí escribe Bueno y sale de la cadena
average >= 11 no se evalúa
$ echo 15 | build/mention
Bueno
$ echo 17 | build/mention
Excelente
$ echo 21 | build/mention
Promedio fuera de rango

Error común. Como gana la primera condición verdadera, el orden importa. Si la condición más amplia va primero, las demás nunca se alcanzan:

if (average >= 11) {
    printf("Regular\n");
} else if (average >= 14) {
    printf("Bueno\n");
} else if (average >= 17) {
    printf("Excelente\n");
} else {
    printf("Insuficiente\n");
}
$ gcc -Wall -Wextra -std=c17 errors/mention_wrong_order.c -o build/mention_wrong_order
$ echo 18 | build/mention_wrong_order
Regular

Otra vez, gcc no avisa: es C válido.

Decisiones anidadas

Un if puede ir dentro de otro. Eso se llama anidar. Las preguntas de repaso de la semana 1 pedían un algoritmo para encontrar el mayor de tres números. Con decisiones anidadas queda así:

algoritmo mayor_de_tres()
    leer a, b, c
    si a > b entonces
        si a > c entonces
            escribir a
        sino
            escribir c
        fin si
    sino
        si b > c entonces
            escribir b
        sino
            escribir c
        fin si
    fin si
fin algoritmo

Y en C, en max_of_three.c:

if (a > b) {
    if (a > c) {
        printf("%d\n", a);
    } else {
        printf("%d\n", c);
    }
} else {
    if (b > c) {
        printf("%d\n", b);
    } else {
        printf("%d\n", c);
    }
}

Seguimos el programa con 4 9 2. Se hacen solo dos de las tres preguntas:

Pregunta Respuesta Qué se descarta
a > b, es decir, 4 > 9 no a no puede ser el mayor
b > c, es decir, 9 > 2 sí c no puede ser el mayor
se escribe b
$ echo 4 9 2 | build/max_of_three
9
Árbol de decisiones. Arriba, el rombo a > b. Por sí baja al rombo a > c, y de ahí a escribir a por sí y a escribir c por no. Por no baja al rombo b > c, y de ahí a escribir b por sí y a escribir c por no. Resaltado en rojo, el camino de los datos 4 9 2: 4 > 9 es falso, va por no; 9 > 2 es cierto, va por sí, y llega a escribir b, que escribe 9Árbol de decisiones. Arriba, el rombo a > b. Por sí baja al rombo a > c, y de ahí a escribir a por sí y a escribir c por no. Por no baja al rombo b > c, y de ahí a escribir b por sí y a escribir c por no. Resaltado en rojo, el camino de los datos 4 9 2: 4 > 9 es falso, va por no; 9 > 2 es cierto, va por sí, y llega a escribir b, que escribe 9
Cada if anidado parte un camino en dos, y así salen cuatro caminos. Una ejecución recorre uno solo, con dos preguntas.

Hay cuatro caminos, así que hacen falta al menos cuatro pruebas. También conviene probar empates, como 5 5 1. Las pruebas de make test revisan todos esos casos.

Error común. Sin llaves, un else se une al if más cercano que no tenga else, no al que parece por la sangría:

if (a > 0)
    if (b > 0)
        printf("Ambos positivos\n");
else
    printf("a no es positivo\n");

Ese else pertenece a if (b > 0). Con a = 5 y b = -1, el mensaje es falso:

$ gcc -Wall -Wextra -std=c17 errors/dangling_else.c -o build/dangling_else
errors/dangling_else.c: In function ‘main’:
errors/dangling_else.c:12:8: warning: suggest explicit braces to avoid ambiguous ‘else’ [-Wdangling-else]
   12 |     if (a > 0)
      |        ^
$ echo 5 -1 | build/dangling_else
a no es positivo

Con llaves, este problema no existe.

switch: elegir según un valor

Cuando una decisión pregunta muchas veces por el mismo valor entero, C ofrece switch. Es una herramienta de C: en pseudocódigo seguimos escribiendo una cadena de sino si.

Este es el precio de un menú según la opción elegida, en combo.c. Los precios están en céntimos.

switch (option) {
    case 1:
        price = 1200;
        break;
    case 2:
        price = 1500;
        break;
    case 3:
        price = 1800;
        break;
    default:
        printf("Opción inválida\n");
        return 1;
}

Se lee así:

  • switch (option) calcula el valor de option.
  • La ejecución salta al case que tiene ese valor.
  • Desde ahí sigue línea por línea hasta encontrar break, que sale del switch.
  • Si ningún case coincide, salta a default. No es obligatorio, pero conviene ponerlo siempre: es el lugar natural para rechazar lo inesperado.
$ echo 2 | build/combo
Precio: 1500 céntimos
$ echo 9 | build/combo; echo $?
Opción inválida
1

Olvidar el break

Un case es solo una etiqueta, un punto al que se salta. No es un bloque. Si falta el break, la ejecución no se detiene al llegar al siguiente case: lo atraviesa y sigue. Esto se llama fall through (caer de largo).

switch (option) {
    case 1:
        price = 1200;
    case 2:
        price = 1500;
    case 3:
        price = 1800;
        break;

Con option = 1, se asigna 1200, luego 1500, luego 1800, y recién ahí un break saca al programa del switch. Las opciones 1 y 2 terminan costando lo mismo que la 3.

Animación con el código del switch a la izquierda, con las etiquetas case en azul, y las cajas option, en 1, y price, en 0, a la derecha. Un punto marca la línea que se ejecuta. Primero, salta a case 1 y guarda 1200. Luego pasa por la etiqueta case 2 sin detenerse y guarda 1500. Luego pasa por case 3 y guarda 1800. Al final, el break saca a la ejecución del switch, y el precio es 1800 céntimosAnimación con el código del switch a la izquierda, con las etiquetas case en azul, y las cajas option, en 1, y price, en 0, a la derecha. Un punto marca la línea que se ejecuta. Primero, salta a case 1 y guarda 1200. Luego pasa por la etiqueta case 2 sin detenerse y guarda 1500. Luego pasa por case 3 y guarda 1800. Al final, el break saca a la ejecución del switch, y el precio es 1800 céntimos
Una etiqueta case marca adónde saltar, pero no detiene a quien viene de arriba. Solo el break sale del switch.

Gracias a -Wextra, gcc avisa:

$ gcc -Wall -Wextra -std=c17 errors/combo_no_break.c -o build/combo_no_break
errors/combo_no_break.c: In function ‘main’:
errors/combo_no_break.c:15:19: warning: this statement may fall through [-Wimplicit-fallthrough=]
   15 |             price = 1200;
      |             ~~~~~~^~~~~~
errors/combo_no_break.c:16:9: note: here
   16 |         case 2:
      |         ^~~~
errors/combo_no_break.c:17:19: warning: this statement may fall through [-Wimplicit-fallthrough=]
   17 |             price = 1500;
      |             ~~~~~~^~~~~~
errors/combo_no_break.c:18:9: note: here
   18 |         case 3:
      |         ^~~~
$ echo 1 | build/combo_no_break
Precio: 1800 céntimos

Varias etiquetas para un mismo caso

Esa misma regla sirve para algo útil. Si varias etiquetas van seguidas, sin instrucciones entre ellas, todas llevan al mismo código. gcc no avisa en este caso, porque está claro que es a propósito. Así se escriben los días de un mes en month_days.c:

switch (month) {
    case 4:
    case 6:
    case 9:
    case 11:
        days = 30;
        break;
    case 2:
        days = 28;
        break;
    case 1:
    case 3:
    case 5:
    case 7:
    case 8:
    case 10:
    case 12:
        days = 31;
        break;
    default:
        printf("Mes inválido\n");
        return 1;
}
$ echo 4 | build/month_days
30 días
$ echo 12 | build/month_days
31 días
$ echo 13 | build/month_days
Mes inválido

Este programa supone un año no bisiesto. Febrero queda como ejercicio cuando hayan hecho el laboratorio.

switch con caracteres

Como char es un número pequeño, switch también funciona con caracteres. En vowel.c el case se escribe con comillas simples:

char letter;

if (scanf(" %c", &letter) != 1) {
switch (letter) {
    case 'a':
    case 'e':
    ...
    case 'U':
        printf("Vocal\n");
        break;
    default:
        printf("No es vocal\n");
}

Fíjense en el espacio antes de %c en el scanf, el de la semana 2. %d y %lf saltan solos los espacios y los saltos de línea. %c no: lee el siguiente byte, sea cual sea. El espacio en el formato le pide a scanf que primero salte todo eso.

¿Y si escribimos una á? Recuerden de la semana pasada que en UTF-8 la á ocupa 2 bytes. %c lee un solo byte, el primero de los dos, que no coincide con ningún case.

$ echo a | build/vowel
Vocal
$ echo á | build/vowel
No es vocal

Por eso el comentario del programa dice “sin tilde”. Trabajar con texto de verdad llega con las cadenas, en la semana 10.

Error común. 'a' es un carácter, un número. "a" es otra cosa, una cadena, que veremos más adelante. Por eso letter == "a" está mal, y gcc lo señala con comparison between pointer and integer. Para comparar un char, siempre comillas simples.

Lo que switch no puede hacer

switch tiene límites:

  • La expresión tiene que ser entera (int o char). Con double no compila.
  • Cada case lleva un valor constante, no una variable.
  • No hay rangos ni comparaciones: case >= 11: no existe en C.
$ gcc -Wall -Wextra -std=c17 errors/switch_double.c -o build/switch_double
errors/switch_double.c: In function ‘main’:
errors/switch_double.c:7:13: error: switch quantity not an integer
    7 |     switch (grade) {
      |             ^~~~~

Entonces, ¿cuándo usar cada uno?

Situación Conviene
Un valor entero o char que se compara con varias constantes switch
Rangos, como average >= 14 if / else if
Valores double if / else if
Condiciones sobre varias variables if / else if

Todo switch se puede escribir como una cadena de if. Lo contrario no siempre es posible.

Lógica booleana

Muchas preguntas son compuestas. “La nota está entre 0 y 20” son en realidad dos preguntas: ¿es mayor o igual que 0?, y ¿es menor o igual que 20? Para combinarlas están los operadores lógicos.

Pseudocódigo C Se lee Es verdadero cuando
y && y ambos lados son verdaderos
o || o al menos uno es verdadero
no ! no el valor es falso

A estas operaciones sobre verdadero y falso se les llama lógica booleana, por el matemático George Boole, que las estudió en el siglo XIX.

El “o” de la lógica es inclusivo: a || b es cierto también cuando los dos lados son ciertos. No es como el “o” de “sopa o ensalada”, que suele querer decir una de las dos.

Tablas de verdad

Como cada lado solo puede ser falso o verdadero, hay pocas combinaciones. Una tabla de verdad las lista todas, con el resultado de cada una. Este programa calcula la tabla con el propio C:

En %4d, el 4 es el ancho mínimo: si el número es más corto, printf lo completa con espacios a la izquierda.

printf("a b | a&&b a||b !a\n");
printf("0 0 | %4d %4d %2d\n", 0 && 0, 0 || 0, !0);
printf("0 1 | %4d %4d %2d\n", 0 && 1, 0 || 1, !0);
printf("1 0 | %4d %4d %2d\n", 1 && 0, 1 || 0, !1);
printf("1 1 | %4d %4d %2d\n", 1 && 1, 1 || 1, !1);
$ build/truth_table
a b | a&&b a||b !a
0 0 |    0    0  1
0 1 |    0    1  1
1 0 |    0    1  0
1 1 |    1    1  0

Vale la pena aprendérsela. Sirve para evaluar cualquier condición a mano: se sustituye cada comparación por 0 o 1 y se aplica la tabla.

Precedencia

Ya conocen la precedencia de la aritmética. Los nuevos operadores se ubican así, de mayor a menor:

Orden Operadores Ejemplo
1 !, y el signo - de un número !a
2 *, /, % a * 2
3 +, - a + 1
4 <, <=, >, >= a < b
5 ==, != a == b
6 && a && b
7 || a || b
8 = x = a

La idea general es natural: primero se hacen las cuentas, luego se compara, y al final se combina con && y ||. Evaluemos a mano con x = 5 y y = 2, igual que el programa precedence.c:

Expresión Paso a paso Valor
x + 1 > y * 3 6 > 6 0
x > 3 && x < 10 1 && 1 1
(x > 3 && y > 3) || y == 2 (1 && 0) || 1 1
x > 3 && (y > 3 || y == 2) 1 && (0 || 1) 1
!(x > 3) !1 0
$ build/precedence
0
1
1
1
0

&& va antes que ||, igual que * antes que +. Pero esa regla se olvida fácil, y gcc pide paréntesis para aclararlo:

$ gcc -Wall -Wextra -std=c17 errors/and_or_no_parens.c -o build/and_or_no_parens
errors/and_or_no_parens.c: In function ‘main’:
errors/and_or_no_parens.c:9:22: warning: suggest parentheses around ‘&&’ within ‘||’ [-Wparentheses]
    9 |     printf("%d\n", a && b || c);
      |                    ~~^~~~

Error común. ! tiene mucha precedencia, así que !a == b significa (!a) == b, no !(a == b). Con a = 5 y b = 3, !a vale 0, y 0 == 3 es falso.

$ gcc -Wall -Wextra -std=c17 errors/not_precedence.c -o build/not_precedence
errors/not_precedence.c: In function ‘main’:
errors/not_precedence.c:8:12: warning: logical not is only applied to the left hand side of comparison [-Wlogical-not-parentheses]
    8 |     if (!a == b) {
      |            ^~
errors/not_precedence.c:8:9: note: add parentheses around left hand side expression to silence this warning
    8 |     if (!a == b) {
      |         ^~
      |         ( )
$ build/not_precedence
a es igual a b

El programa dice que 5 es igual a 3. Lo que se quería decir es a != b.

Dos condiciones que parecen bien y no lo están

En matemática escribimos 0 ≤ nota ≤ 20. En C eso compila, pero significa otra cosa. Los operadores se agrupan de izquierda a derecha, así que 0 <= grade <= 20 es (0 <= grade) <= 20. Lo de adentro vale 0 o 1, y tanto 0 como 1 son menores o iguales que 20. La condición es siempre verdadera, incluso con una nota de 25:

$ gcc -Wall -Wextra -std=c17 errors/chained_compare.c -o build/chained_compare
errors/chained_compare.c: In function ‘main’:
errors/chained_compare.c:12:20: warning: comparison of constant ‘20’ with boolean expression is always true [-Wbool-compare]
   12 |     if (0 <= grade <= 20) {
      |                    ^~
errors/chained_compare.c:12:11: warning: comparisons like ‘X<=Y<=Z’ do not have their mathematical meaning [-Wparentheses]
   12 |     if (0 <= grade <= 20) {
      |         ~~^~~~~~~~
$ echo 25 | build/chained_compare
En rango

Lo correcto es hacer las dos preguntas: grade >= 0 && grade <= 20.

El segundo caso es peor, porque gcc no avisa. En castellano decimos “si la opción es 1 o 2”, y entonces escribimos option == 1 || 2. Pero cada lado de || es una condición completa. El lado derecho es solo 2, y 2 no es cero, así que es verdadero. La condición se cumple para cualquier opción:

$ gcc -Wall -Wextra -std=c17 errors/or_constant.c -o build/or_constant
$ echo 7 | build/or_constant
Opción 1 o 2

Lo correcto es option == 1 || option == 2.

Error común. Cada lado de && y || tiene que ser una pregunta completa. Si al leerlo en voz alta le falta el sujeto, le falta código.

Cortocircuito

&& y || evalúan siempre primero el lado izquierdo, y a veces no llegan a mirar el derecho, porque ya no hace falta.

  • En A && B, si A es falso, el resultado ya es falso. B no se evalúa.
  • En A || B, si A es verdadero, el resultado ya es verdadero. B no se evalúa.

Esto se llama cortocircuito (short-circuit), y C lo garantiza. No es una optimización opcional, sino una regla que podemos usar a nuestro favor. Por ejemplo, para evitar una división entre 0, en short_circuit.c:

if (n != 0 && 10 / n > 1) {
    printf("10 / %d es mayor que 1\n", n);
} else {
    printf("No se cumple\n");
}
n n != 0 10 / n > 1 Resultado
5 1 2 > 1, vale 1 1
20 1 0 > 1, vale 0 0
0 0 no se evalúa 0
$ echo 5 | build/short_circuit
10 / 5 es mayor que 1
$ echo 0 | build/short_circuit
No se cumple

El programa de arriba se compiló con -fsanitize=undefined, como todos los de make. El sanitizer de la semana pasada también vigila la división entera entre 0. No se quejó, porque la división nunca ocurrió.

El orden importa. Si invertimos los dos lados, primero se divide:

if (10 / n > 1 && n != 0) {
$ gcc -Wall -Wextra -std=c17 -g -fsanitize=undefined errors/unsafe_division.c -o build/unsafe_division
$ echo 0 | build/unsafe_division
errors/unsafe_division.c:12:12: runtime error: division by zero
No se cumple

gcc no avisa al compilar, porque el valor de n se conoce recién al ejecutar. Quien detecta el error es el sanitizer, mientras el programa corre. Dividir un entero entre 0 es comportamiento indefinido: el estándar de C no dice qué debe pasar. La salida de arriba viene de una computadora con procesador ARM, donde el programa siguió de largo. En uno x86-64, como el de muchas laptops, lo habitual es que el programa termine con un error. Ninguna de las dos cosas es una respuesta correcta. Lo único seguro es no dividir.

Idea clave. Pongan primero la condición que protege, y después la que depende de ella: “¿se puede hacer?” antes de “hagámoslo”.

El cortocircuito también permite leer y validar en una sola condición, como en read_in_range.c:

if (scanf("%d", &n) == 1 && n >= 1 && n <= 12) {

Si scanf falla, n no tiene valor, pero tampoco se llega a mirar. Es más compacto, aunque no distingue entre los dos problemas: abc y 13 reciben el mismo mensaje.

$ echo 12 | build/read_in_range
Mes válido: 12
$ echo abc | build/read_in_range
Mes inválido
$ echo 13 | build/read_in_range
Mes inválido

Leyes de De Morgan

¿Qué es lo contrario de “la nota está entre 0 y 20”? Es “la nota es menor que 0 o mayor que 20”. Al negar, el “y” se convierte en “o”. Eso dicen las leyes de De Morgan, llamadas así por el matemático Augustus De Morgan:

  • !(A && B) es lo mismo que !A || !B
  • !(A || B) es lo mismo que !A && !B

Se puede comprobar con una tabla de verdad, revisando todas las combinaciones. Esta es la primera ley:

A B A && B !(A && B) !A !B !A || !B
0 0 0 1 1 1 1
0 1 0 1 1 0 1
1 0 0 1 0 1 1
1 1 1 0 0 0 0

Las columnas cuarta y séptima son iguales en todas las filas, así que las dos expresiones son equivalentes.

Para simplificar una condición con comparaciones, se aplica la ley y se niega cada comparación. Negar una comparación es cambiarla por la opuesta.

Comparación Su negación
a == b a != b
a < b a >= b
a > b a <= b

Observen que lo contrario de < no es >, sino >=. El caso de la igualdad tiene que caer en algún lado. Hagamos dos ejemplos.

!(grade >= 0 && grade <= 20): se cambia && por || y se niega cada lado. Queda grade < 0 || grade > 20, que es justo la condición que usa mention.c para rechazar.

!(x >= 0 && y < 10): queda x < 0 || y >= 10. El programa de_morgan.c evalúa las dos formas de esa expresión y de otra, de la segunda ley:

$ echo 5 12 | build/de_morgan
!(x >= 0 && y < 10) vale 1
x < 0 || y >= 10    vale 1
!(x == 0 || y == 0) vale 1
x != 0 && y != 0    vale 1

Un solo caso no demuestra nada, pero make test lo corre sobre una tabla de muchos pares de valores, y las dos formas coinciden siempre. Esto vale con enteros y con char. Con double existe un valor especial, NaN, para el que la negación de < no equivale a >=, pero no lo necesitaremos en este curso.

Idea clave. Cuando una condición tiene un ! delante de un paréntesis, casi siempre se lee mejor si se aplica De Morgan y el ! desaparece.

Compuertas: la lógica en hardware

Hasta aquí, && y || han sido instrucciones de C. Pero la lógica también es la base física de la computadora. Lo que hace la CPU, incluidas las sumas, se construye con circuitos que calculan operaciones lógicas sobre bits.

En un circuito digital, un bit es una señal eléctrica: un voltaje alto representa 1 y uno bajo representa 0. Una compuerta lógica (logic gate) es un circuito pequeño, hecho con unos pocos transistores, que recibe bits y produce un bit. Un transistor es, en esencia, un interruptor que se controla con electricidad. Veremos más de lo que hay dentro de la computadora en la semana 9.

Las cuatro compuertas de hoy son estas.

a b AND OR XOR NOT a
0 0 0 0 0 1
0 1 0 1 1 1
1 0 0 1 1 0
1 1 1 1 0 0

AND, OR y NOT ya las conocen: son &&, || y !. La nueva es XOR (o exclusivo): da 1 cuando los dos bits son distintos. Es el “o” de “sopa o ensalada”. C no tiene un operador lógico especial para eso, pero no lo necesitamos: si a y b solo valen 0 o 1, XOR es justamente a != b.

printf("AND %d\n", a && b);
printf("OR  %d\n", a || b);
printf("NOT %d\n", !a);
printf("XOR %d\n", a != b);
$ echo 1 1 | build/gates
AND 1
OR  1
NOT 0
XOR 0

Dos diferencias con C. Una compuerta recibe bits, mientras que && acepta cualquier número. Por eso a != b solo funciona como XOR si a y b valen 0 o 1, y gates.c revisa eso al leerlos. Además, una compuerta no tiene cortocircuito: es un circuito conectado, y sus entradas siempre llegan.

Un sumador de un bit

La semana pasada sumaron en binario a mano. Veamos la columna más simple, con dos bits a y b:

a b a + b en binario suma acarreo
0 0 00 0 0
0 1 01 1 0
1 0 01 1 0
1 1 10 0 1

Comparen esa tabla con la de las compuertas. La columna suma es XOR, y la columna acarreo es AND. Eso significa que sumar dos bits se puede hacer con dos compuertas.

Pero una columna intermedia recibe además el acarreo que viene de la derecha. Son tres bits de entrada. Un sumador (adder) completo de un bit lo resuelve en dos pasos, como cuando sumamos tres números: primero a con b, y luego el resultado con el acarreo que llega.

Paso Fórmula con compuertas En C (full_adder.c)
Parcial a XOR b int partial = a != b;
Suma parcial XOR acarreo que llega int sum = partial != carry_in;
Acarreo que sale (a AND b) OR (parcial AND acarreo que llega) int carry_out = (a && b) || (partial && carry_in);

Una variable int puede guardar el resultado de una comparación: partial queda en 0 o 1. El acarreo sale si a y b ya eran ambos 1, o si eran distintos y además llegó un acarreo. La tabla completa tiene 8 filas, y coincide con a + b + acarreo contado en binario:

a b acarreo que llega parcial suma acarreo que sale
0 0 0 0 0 0
0 0 1 0 1 0
0 1 0 1 1 0
0 1 1 1 0 1
1 0 0 1 1 0
1 0 1 1 0 1
1 1 0 0 0 1
1 1 1 0 1 1
$ echo 1 1 1 | build/full_adder
suma 1, acarreo 1

Cuatro bits en cadena

Para sumar números de 4 bits se ponen 4 sumadores en fila: el acarreo que sale de cada uno entra al siguiente, de derecha a izquierda. adder4.c hace exactamente eso, y como todavía no sabemos escribir ciclos, repite la cuenta para cada columna. Al leer ese archivo fíjense en la repetición: es justo lo que un ciclo, la próxima semana, nos va a ahorrar.

$ echo 0101 0011 | build/adder4
acarreos: 0 1 1 1
suma:     1000

El programa escribe los acarreos que salen de cada columna, de izquierda a derecha, igual que la suma. Sumamos 5 + 3, que da 8. Columna por columna, empezando por la derecha:

Columna a b Acarreo que llega Suma Acarreo que sale
0 (derecha) 1 1 0 0 1
1 0 1 1 0 1
2 1 0 1 0 1
3 (izquierda) 0 0 1 1 0
Cuatro cajas de sumador en fila, de izquierda a derecha las columnas 3, 2, 1 y 0. Arriba de cada una entran los bits a y b: 0 y 0, 1 y 0, 0 y 1, 1 y 1. Debajo de cada una sale el bit de suma: 1, 0, 0 y 0. Entre las cajas, las flechas van de derecha a izquierda: entra un acarreo 0 a la columna 0, de ahí sale 1, de la columna 1 sale 1, de la columna 2 sale 1, y de la columna 3 sale 0. Abajo se lee 0101 + 0011 = 1000, es decir, 5 + 3 = 8Cuatro cajas de sumador en fila, de izquierda a derecha las columnas 3, 2, 1 y 0. Arriba de cada una entran los bits a y b: 0 y 0, 1 y 0, 0 y 1, 1 y 1. Debajo de cada una sale el bit de suma: 1, 0, 0 y 0. Entre las cajas, las flechas van de derecha a izquierda: entra un acarreo 0 a la columna 0, de ahí sale 1, de la columna 1 sale 1, de la columna 2 sale 1, y de la columna 3 sale 0. Abajo se lee 0101 + 0011 = 1000, es decir, 5 + 3 = 8
El mismo sumador repetido cuatro veces. Cada columna le pasa su acarreo a la de su izquierda.

¿Y si el acarreo de la última columna es 1? Si leemos los bits como números sin signo, el resultado no cabe en 4 bits. 8 + 8 debería dar 16, y quedan cuatro ceros:

$ echo 1000 1000 | build/adder4
acarreos: 1 0 0 0
suma:     0000

Es un overflow, como los de la semana pasada. El circuito no se queja: da 4 bits y el acarreo final queda aparte.

Con números en complemento a dos, el mismo circuito sirve, pero ese acarreo ya no es la señal. 1111 es -1, y -1 + 1 da 0: un resultado correcto, aunque salga un acarreo.

$ echo 1111 0001 | build/adder4
acarreos: 1 1 1 1
suma:     0000

Y hay overflow sin acarreo final: 0111 + 0001 es 7 + 1, y da 1000, que en complemento a dos es -8. Todo esto explica lo que pasa en hardware, pero no cambia la regla de C: para un int, el overflow es comportamiento indefinido, y el programa no puede contar con lo que hará el circuito.

Las CPU reales usan los mismos principios con más bits, y con variantes del circuito que evitan esperar el acarreo columna por columna, porque así es más rápido.

Idea clave. La aritmética de la computadora no es magia, sino lógica. Con compuertas se arma un sumador de un bit, y con varios de ellos, la suma de un int.

El laboratorio, paso a paso

El laboratorio tiene tres programas. Los archivos de partida están en code/week-04/lab. En cada uno, el comentario inicial dice exactamente qué debe escribir el programa. make test-lab los comprueba: falla hasta que terminen. Aquí están las ideas, no el código.

Una nota de 0 a 20

Hay que leer una nota entera y decir si aprobó. En este laboratorio se aprueba con 11 o más. El programa tiene cuatro caminos:

Dato Qué escribe Estado de salida
abc Entrada inválida 1
-1 o 21 Nota fuera de rango 1
0 a 10 Desaprobado 0
11 a 20 Aprobado 0

Revisen primero la lectura, después el rango, y al final la decisión. Antes de correr make test-lab, escriban la lista de datos que van a probar: -1, 0, 10, 11, 20, 21 y abc. Son los bordes de cada camino.

Un año bisiesto

Esta es la regla del calendario, en tres partes:

  • Un año divisible entre 4 es bisiesto.
  • Salvo si es divisible entre 100: esos no lo son.
  • Salvo si es divisible entre 400: esos sí lo son.

“Divisible entre 4” se escribe con el resto: year % 4 == 0. La regla se puede escribir con una cadena de if o con una sola condición que use && y ||. Prueben las dos si tienen tiempo. Si eligen una sola condición, recuerden que gcc pide paréntesis para && dentro de ||. Estos son los resultados esperados, y cada uno prueba una parte de la regla:

Año ¿Divisible entre 4? ¿Entre 100? ¿Entre 400? ¿Bisiesto?
2026 no no no no
2024 sí no no sí
1900 sí sí no no
2000 sí sí sí sí

Una calculadora con switch

El programa lee algo como 8 / 2: un número, un operador y otro número. Usen double para los números y char para el operador. El formato es "%lf %c %lf": recuerden que scanf lee un double con %lf, y que el espacio antes de %c salta los espacios que haya antes del operador. Si scanf no devuelve 3, los datos no se pudieron leer.

Con el operador se hace el switch, con un case para cada uno de '+', '-', '*' y '/', y un default para los demás.

Antes de dividir, revisen si el divisor es 0. Dentro de case '/': va un if. ¿No dijimos que los double no se comparan con ==? Dijimos que no se comparan así los resultados de cuentas, cuando esperamos que dos valores “deberían” ser iguales. Aquí la pregunta es otra: si el divisor es exactamente 0, y eso == sí lo puede responder. Un divisor muy pequeño que no es 0 no es una división entre 0.

La Práctica 1

Esta semana, la sesión de laboratorio es para la Práctica 1. Los tres programas de arriba son trabajo autónomo y conviene hacerlos antes. La práctica cubre algoritmos, variables, representación de datos y decisiones: todo lo visto hasta este capítulo. El formato y las reglas están en practicas/README.md. El programa debe compilar sin advertencias y responder bien también a las entradas inválidas.

Resumen

  • Una condición es una expresión verdadera o falsa. En C, 0 es falso y cualquier otro número es verdadero. Los operadores relacionales dan 1 o 0.
  • if ejecuta un bloque si la condición es verdadera. else da el otro camino. else if encadena opciones, y gana la primera condición verdadera.
  • Todo scanf se revisa: si no devuelve la cantidad pedida, el programa avisa y termina con return 1. Los datos leídos se validan aparte.
  • = asigna y == compara. Compilen con -Wall -Wextra, y lean las advertencias.
  • switch elige según un valor entero o char. Cada case necesita su break, salvo cuando varias etiquetas comparten el mismo código.
  • &&, || y ! combinan condiciones. Cada lado es una condición completa: grade >= 0 && grade <= 20, nunca 0 <= grade <= 20.
  • El cortocircuito deja de evaluar en cuanto se conoce el resultado. La condición que protege va primero: n != 0 && 10 / n > 1.
  • De Morgan: !(A && B) es !A || !B, y !(A || B) es !A && !B.
  • Prueben cada camino y los bordes de cada condición.
  • Las compuertas AND, OR, NOT y XOR hacen estas operaciones en hardware. XOR y AND bastan para sumar dos bits; varios sumadores de un bit en cadena suman un número.

Ejercicios

Los ejercicios no necesitan ciclos ni funciones. Todo programa revisa su scanf.

  1. Con x = 4 e y = 0, evalúen a mano, mostrando los pasos, y comprueben con un programa: x > 3 && y, x == 4 || y == 1, !x || !y, x + y >= 2 * x.

  2. ¿Qué escribe este fragmento? Respondan sin compilar y luego compruébenlo. ¿Qué advertencia da gcc?

    int x = 0;
    if (x = 3) { printf("A\n"); } else { printf("B\n"); }
    printf("%d\n", x);
  3. Escriban la tabla de verdad de !(a || b) y la de !a && !b. ¿Qué ley comprueban?

  4. Simplifiquen sin ! delante de un paréntesis: !(x < 0 || x > 20), !(a == b && c >= 3), !(n % 2 == 0 || n > 100).

  5. Escriban en pseudocódigo y en C un programa que lea dos enteros y escriba el mayor, o Iguales si lo son. ¿Cuántos caminos tiene? Pruébenlo con un dato para cada uno.

  6. En errors/combo_no_break.c, sigan a mano qué pasa con la opción 2 y con la opción 4, usando una tabla con los valores de price. Comprueben su respuesta al ejecutarlo.

  7. Reescriban month_days.c con if y else if, sin switch. Usen || para los meses con la misma cantidad de días. ¿Cuál versión se lee mejor?

  8. Después del laboratorio, modifiquen month_days.c para que lea el mes y el año, y dé los días correctos de febrero. Pruébenlo con 2 2024, 2 1900, 2 2000 y 2 2026.

  9. Escriban un programa que lea un char y diga si es una letra minúscula, una letra mayúscula, un dígito u otra cosa, usando rangos como c >= 'a' && c <= 'z'. ¿Por qué no conviene usar switch aquí? ¿Qué escribe con una ñ, y por qué?

  10. Escriban un programa que lea tres enteros entre 1 y 1000, los lados de un triángulo. Debe decir si forman un triángulo (cada lado es menor que la suma de los otros dos) y si es equilátero, isósceles o escaleno. Encuentren datos para cada camino.

  11. Escriban XOR usando solo &&, || y !, sin !=. Comprueben su fórmula con una tabla de verdad de cuatro filas. Después, escriban un ! usando solo XOR y un 1.

  12. Sumen a mano 1011 + 0110 con un sumador por columna, en una tabla como la del capítulo. Comprueben con build/adder4. ¿Qué indica el acarreo final? ¿Cuánto dan esos mismos números en decimal, y por qué la suma de 4 bits no coincide?