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 corranmakepara compilar los ejemplos ymake testpara comprobarlos. Los comandos de este capítulo se corren desde ahí. Los programas deerrors/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: “
ifen 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.
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
boolsi se incluye<stdbool.h>, con los valorestrueyfalse. Es cómodo, pero en este curso basta conint: las comparaciones de C danintde 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
scanfno atrapa todos los problemas.%dlee mientras haya cifras y deja el resto sin leer, así que15.5se lee como 15 y12abccomo 12, yscanfdevuelve 1 en ambos casos. Tampoco avisa si el número no cabe en unint. 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 descanfes 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 % 2vale-1, no1. 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
gccno avisa nada, porque el programa es válido: solo está mal pensado. Preguntenn % 2 == 0para par, yn % 2 != 0para 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 RegularOtra vez,
gccno 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
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
elsese une alifmás cercano que no tengaelse, 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
elsepertenece aif (b > 0). Cona = 5yb = -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 positivoCon 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 deoption.- La ejecución salta al
caseque tiene ese valor. - Desde ahí sigue línea por línea hasta encontrar
break, que sale delswitch. - Si ningún
casecoincide, salta adefault. 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.


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 esoletter == "a"está mal, ygcclo señala concomparison between pointer and integer. Para comparar unchar, siempre comillas simples.
Lo que switch no puede hacer
switch tiene límites:
- La expresión tiene que ser entera (
intochar). Condoubleno compila. - Cada
caselleva 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 == bsignifica(!a) == b, no!(a == b). Cona = 5yb = 3,!avale 0, y0 == 3es 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 bEl 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, siAes falso, el resultado ya es falso.Bno se evalúa. - En
A || B, siAes verdadero, el resultado ya es verdadero.Bno 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 |
¿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.
ifejecuta un bloque si la condición es verdadera.elseda el otro camino.else ifencadena opciones, y gana la primera condición verdadera.- Todo
scanfse revisa: si no devuelve la cantidad pedida, el programa avisa y termina conreturn 1. Los datos leídos se validan aparte. =asigna y==compara. Compilen con-Wall -Wextra, y lean las advertencias.switchelige según un valor entero ochar. Cadacasenecesita subreak, salvo cuando varias etiquetas comparten el mismo código.&&,||y!combinan condiciones. Cada lado es una condición completa:grade >= 0 && grade <= 20, nunca0 <= 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.
-
Con
x = 4ey = 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. -
¿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); -
Escriban la tabla de verdad de
!(a || b)y la de!a && !b. ¿Qué ley comprueban? -
Simplifiquen sin
!delante de un paréntesis:!(x < 0 || x > 20),!(a == b && c >= 3),!(n % 2 == 0 || n > 100). -
Escriban en pseudocódigo y en C un programa que lea dos enteros y escriba el mayor, o
Igualessi lo son. ¿Cuántos caminos tiene? Pruébenlo con un dato para cada uno. -
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 deprice. Comprueben su respuesta al ejecutarlo. -
Reescriban
month_days.cconifyelse if, sinswitch. Usen||para los meses con la misma cantidad de días. ¿Cuál versión se lee mejor? -
Después del laboratorio, modifiquen
month_days.cpara que lea el mes y el año, y dé los días correctos de febrero. Pruébenlo con2 2024,2 1900,2 2000y2 2026. -
Escriban un programa que lea un
chary diga si es una letra minúscula, una letra mayúscula, un dígito u otra cosa, usando rangos comoc >= 'a' && c <= 'z'. ¿Por qué no conviene usarswitchaquí? ¿Qué escribe con unañ, y por qué? -
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.
-
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. -
Sumen a mano
1011 + 0110con un sumador por columna, en una tabla como la del capítulo. Comprueben conbuild/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?