Apuntes · Capítulo 2De C a C++ y costos
Ownership y templates
Ownership y templates
La semana pasada usaron std::string y std::vector sin pensar en su memoria. Un vector se crea, crece, se
copia, y al salir de la función desaparece, sin un solo free. Esa comodidad no es magia: alguien escribió una
clase que hace por ustedes lo que en Introducción a la Programación hacían a mano.
Este semestre vamos a construir esas clases, así que hoy abrimos la caja. Veremos cómo una clase se vuelve owner de un bloque de memoria, qué pasa cuando alguien la copia sin que ella esté preparada, y qué significan exactamente copiar y mover un objeto. Después haremos que el código sirva para cualquier tipo, con templates. Y terminaremos con una distinción que usaremos todo el curso: una cosa es lo que una estructura promete, y otra cómo está hecha. La pila será el primer ejemplo.
Al terminar, tendrán lo necesario para escribir, en el laboratorio, una pila genérica sin fugas.
Ruta de lectura.
- Antes de clase (15 a 20 minutos, obligatorio): Del owner por convención al owner en el tipo, RAII y La copia superficial.
- En clase: La regla de cinco, Copiar de verdad, Mover, Templates, Tipo abstracto e implementación y Invariantes.
- Después de clase: repasen con el Resumen y los Ejercicios, y lean El código de esta semana. Opcional: Open Data Structures, secs. 1.2 (Interfaces) y 2.1 (ArrayStack). El
ArrayStackde esa sección crece cuando se llena; el nuestro todavía no, y el crecimiento llega en la semana 4.
Código. Los programas de este capítulo están en
code/week-02. 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.
Del owner por convención al owner en el tipo
En C, el owner de un bloque es quien debe liberarlo, y el ownership es esa responsabilidad. Es una regla que ustedes acordaron consigo mismos. El lenguaje no sabe nada de ella:
struct int_array numbers = int_array_new(4);
if (numbers.data == NULL) {
return 1;
}
numbers.data[0] = 7;
printf("%d\n", numbers.data[0]);
int_array_free(&numbers);
Si se olvidan de int_array_free, hay una fuga. Si la función tiene tres return, hay que liberar en los tres. Y
si dos variables guardan el mismo struct, nada indica cuál de las dos libera. Todo eso depende de la disciplina
de quien escribe, y por eso necesitaban ASan y valgrind.
C++ permite algo mejor: que la responsabilidad de liberar sea parte del tipo. Para eso hacen falta dos piezas.
new y delete
new crea un objeto en el heap y devuelve su dirección. delete lo destruye y libera su memoria. Para un arreglo
se escriben new[] y delete[].
int* one = new int(7);
std::string* name = new std::string("pila");
std::cout << *one << " " << *name << " " << name->size() << "\n";
delete one;
delete name;
std::size_t n = 4;
int* zeros = new int[n]();
std::string* words = new std::string[n];
words[1] = "hola";
std::cout << zeros[3] << " [" << words[0] << "] [" << words[1] << "]\n";
delete[] zeros;
delete[] words;
$ build/new_basics
7 pila 4
0 [] [hola]
delete de nullptr: no hace nada
La diferencia con malloc y free es de fondo. malloc reserva bytes. new reserva y construye:
malloc y free |
new y delete |
|
|---|---|---|
| Qué recibe | Una cantidad de bytes | Un tipo, y opcionalmente argumentos |
| Qué devuelve | void * |
Un puntero del tipo correcto |
| Constructor | No corre | Corre, para cada elemento en un arreglo |
| Destructor | No corre | Corre, para cada elemento en un arreglo |
| Si no hay memoria | Devuelve NULL |
Lanza la excepción std::bad_alloc |
Cuatro detalles que conviene fijar desde ahora:
new std::string[n]construyencadenas vacías, porquestd::stringtiene un constructor sin argumentos.new int[n]no inicializa nada: unintno tiene constructor, y los valores son basura, como conmalloc. Con paréntesis,new int[n](), quedan en cero.- No hace falta revisar si
newdevolviónullptr. Si no hay memoria, lanza una excepción: un mecanismo que corta la ejecución normal y va saliendo de las funciones, una tras otra, hasta que alguien la atrape. En este curso casi no las atraparemos, pero sí importa que existan, y veremos por qué. deletede unnullptrno hace nada, igual quefree(NULL).
Error común. Lo que se pidió con
new[]se libera condelete[], y lo que se pidió connewse libera condelete. Mezclarlos es comportamiento indefinido. Aquíg++da un aviso porque el caso es muy directo, pero no siempre podrá verlo, y ASan lo detecta al ejecutar:
$ g++ -Wall -Wextra -std=c++20 -g -fsanitize=address,undefined errors/new_delete_mismatch.cpp -o build/new_delete_mismatch
errors/new_delete_mismatch.cpp:5:12: warning: 'void operator delete(void*, long unsigned int)' called on pointer returned from a mismatched allocation function [-Wmismatched-new-delete]
$ build/new_delete_mismatch
=================================================================
==1333==ERROR: AddressSanitizer: alloc-dealloc-mismatch (operator new [] vs operator delete) on 0x502000000010
...
#1 0xaaaadfcd10a8 in main errors/new_delete_mismatch.cpp:5
...
allocated by thread T0 here:
#0 0xffff9714b090 in operator new[](unsigned long) ../../../../src/libsanitizer/asan/asan_new_delete.cpp:98
#1 0xaaaadfcd0efc in main errors/new_delete_mismatch.cpp:3
El destructor
El destructor (destructor) es el método que corre cuando un objeto muere. Se llama como la clase, con ~
delante, no recibe argumentos y no devuelve nada. Una clase tiene exactamente uno.
La clave es cuándo corre. Lo decide el lenguaje, no ustedes:
- Una variable local muere al salir de su alcance (scope), es decir, al cerrar la llave del bloque donde se
declaró. Da igual si se sale por el final, por un
returno por una excepción que alguien atrape. - Las locales de un mismo bloque mueren en el orden inverso al de su creación.
- Un objeto creado con
newmuere cuando alguien hacedelete, y solo entonces.
Esta clase avisa cuando nace y cuando muere:
class Noisy {
public:
explicit Noisy(const std::string& name) : name_(name) {
std::cout << " crea " << name_ << "\n";
}
~Noisy() {
std::cout << " destruye " << name_ << "\n";
}
private:
std::string name_;
};
La palabra explicit impide que el compilador convierta un std::string en un Noisy sin que nadie lo pida. La
pondremos en todo constructor de un solo argumento. Veamos a Noisy en tres situaciones:
int first_positive(int a, int b) {
Noisy guard("guard");
if (a > 0) {
return a;
}
return b;
}
std::cout << "bloque:\n";
{
Noisy x("x");
Noisy y("y");
std::cout << " fin del bloque\n";
}
std::cout << "retorno temprano:\n";
int result = first_positive(5, 6);
std::cout << " resultado " << result << "\n";
std::cout << "new y delete:\n";
Noisy* z = new Noisy("z");
std::cout << " z sigue vivo\n";
delete z;
std::cout << "fin de main\n";
$ build/scope_trace
bloque:
crea x
crea y
fin del bloque
destruye y
destruye x
retorno temprano:
crea guard
destruye guard
resultado 5
new y delete:
crea z
z sigue vivo
destruye z
fin de main
Lean la salida despacio. y muere antes que x, porque nació después. guard muere aunque la función salió por
un return en medio de un if, y lo hace antes de que main escriba el resultado. Y z no murió al cerrar nada:
murió en el delete.
Idea clave. El destructor de una variable local corre por cualquier camino de salida del bloque. Lo que se escribe en un destructor no se puede olvidar.
RAII
Juntemos las dos piezas. Si el constructor pide la memoria y el destructor la libera, y el destructor corre solo, entonces el problema de la fuga desaparece. Esa idea tiene un nombre poco afortunado, que se quedó:
RAII (resource acquisition is initialization) es la técnica de adquirir un recurso en el constructor de un objeto y liberarlo en su destructor. El tiempo de vida del recurso queda atado al del objeto.
Un recurso es cualquier cosa que se pide y se tiene que devolver: un bloque de memoria, un archivo abierto, una conexión. En este curso casi siempre será memoria.
Este es nuestro ejemplo para todo el capítulo: un arreglo de enteros de tamaño fijo, owner de su bloque. Por ahora solo tiene lo necesario para RAII:
class IntBuffer {
public:
explicit IntBuffer(std::size_t size) : data_(new int[size]()), size_(size) {}
~IntBuffer() {
delete[] data_;
}
int& at(std::size_t index) {
return data_[index];
}
std::size_t size() const {
return size_;
}
private:
int* data_;
std::size_t size_;
};
Quien usa esta clase ya no piensa en memoria:
void f() {
IntBuffer numbers(4); // reserva
numbers.at(0) = 7;
if (numbers.at(0) > 5) {
return; // libera
}
numbers.at(1) = 8;
} // libera
Comparen con lo que hacían en CS201:
| C, en CS201 | C++, con RAII | |
|---|---|---|
| Quién es el owner | Lo dice un comentario | Lo dice el tipo: el objeto IntBuffer |
| Cuándo se reserva | Al llamar a int_array_new |
Al crear el objeto |
| Hay que comprobar el resultado | Sí, después de cada reserva | No: si falla, se lanza una excepción |
| Cuándo se libera | Cuando alguien se acuerde | Al salir del alcance |
Una función con tres return |
Tres llamadas a free, o un goto |
Nada que agregar |
| Quién puede tocar el puntero | Cualquiera | Solo los métodos: data_ es privado |
El owner es el mismo de CS201. Lo que cambió es quién se encarga de cumplir la regla.
Hay otra ganancia, que solo se nota con el tiempo. Si una función lanza una excepción y alguien la atrapa, las locales de
todas las funciones que va abandonando corren su destructor. Una clase con RAII no pierde memoria ni en ese caso, y
no hay que escribir nada especial para eso. Si nadie la atrapa, el estándar deja a cada implementación decidir si se
abandonan las funciones de esa forma: con g++, el programa termina sin correr esos destructores. std::exit y
abort tampoco los corren para las locales.
En la biblioteca.
std::stringystd::vectorson clases con RAII. Tambiénstd::ifstream, que abre un archivo en el constructor y lo cierra en el destructor. Por eso no hizo falta liberar nada la semana pasada. La biblioteca también tienestd::unique_ptr, un puntero que es owner de lo que apunta, y es lo normal en un programa de verdad. Aquí no lo usaremos, porque el tema del curso es construir estas clases. Por eso la regla de la guía de estilo:newydeletesolo dentro de una clase con RAII.
La copia superficial
Nuestra IntBuffer parece terminada. Compila, no tiene fugas, y funciona. Hasta que alguien la copia.
Si una clase no dice cómo copiarse, el compilador escribe una copia por ella. Esa copia por defecto copia cada
campo, uno por uno. Para un int es lo correcto. Para un puntero, copia la dirección, no el bloque. A esto se
le llama copia superficial (shallow copy). El programa completo está en errors/shallow_copy.cpp:
int main() {
IntBuffer a(3);
a.at(0) = 10;
IntBuffer b = a;
b.at(0) = 99;
std::cout << "a.at(0) = " << a.at(0) << "\n";
return 0;
}
Así corre el programa, hasta la llave que cierra main:


Dos objetos, y cada uno cree ser el único owner del mismo bloque. Eso rompe el programa de dos maneras. La primera
es que b.at(0) = 99 también cambia a. La segunda llega al cerrar main: b muere primero y libera el bloque,
y después a muere y lo libera otra vez.
Compilemos y corramos. Fíjense en que el compilador no dice nada, ni siquiera con -Wall -Wextra:
$ g++ -Wall -Wextra -std=c++20 -g -fsanitize=address,undefined errors/shallow_copy.cpp -o build/shallow_copy
$ build/shallow_copy
a.at(0) = 99
=================================================================
==1341==ERROR: AddressSanitizer: attempting double-free on 0x502000000010 in thread T0:
#0 0xffff9a79b9c0 in operator delete[](void*) ../../../../src/libsanitizer/asan/asan_new_delete.cpp:155
#1 0xaaaaca672634 in IntBuffer::~IntBuffer() errors/shallow_copy.cpp:9
#2 0xaaaaca672294 in main errors/shallow_copy.cpp:30
...
0x502000000010 is located 0 bytes inside of 12-byte region [0x502000000010,0x50200000001c)
freed by thread T0 here:
#0 0xffff9a79b9c0 in operator delete[](void*) ../../../../src/libsanitizer/asan/asan_new_delete.cpp:155
#1 0xaaaaca672634 in IntBuffer::~IntBuffer() errors/shallow_copy.cpp:9
#2 0xaaaaca67228c in main errors/shallow_copy.cpp:30
...
previously allocated by thread T0 here:
#0 0xffff9a79b090 in operator new[](unsigned long) ../../../../src/libsanitizer/asan/asan_new_delete.cpp:98
#1 0xaaaaca67239c in IntBuffer::IntBuffer(unsigned long) errors/shallow_copy.cpp:7
#2 0xaaaaca671f04 in main errors/shallow_copy.cpp:24
...
SUMMARY: AddressSanitizer: double-free ../../../../src/libsanitizer/asan/asan_new_delete.cpp:155 in operator delete[](void*)
Las direcciones y los números de proceso cambian en cada corrida. Lo demás se lee igual que en CS201, por bloques:
- Qué pasó.
attempting double-free. La pila de llamadas dice dónde: en el destructor, línea 9, llamado desdemain, línea 30. Esa línea 30 es la llave que cierramain. Ningúndeleteaparece escrito enmain, y aun así el error ocurre ahí, porque ahí corren los destructores. - Quién lo liberó antes.
freed by: el mismo destructor, desde la misma línea 30. Es el otro objeto. - Quién lo reservó.
previously allocated by: el constructor, llamado desde la línea 24. Un solo bloque, reservado una vez, enIntBuffer a(3). La línea 26, la copia, no aparece en ninguna parte, porque esa copia no reservó nada. Esa ausencia es la pista.
La región es de 12 bytes: tres enteros de 4 bytes. La primera línea de la salida, a.at(0) = 99, ya mostraba el
problema antes del informe.
La asignación por defecto hace algo parecido, y es un poco peor. Aquí b ya tenía su propio bloque:
IntBuffer a(3);
IntBuffer b(5);
b = a;
Son dos errores en una línea: la misma doble liberación de antes, más una fuga, porque ya nadie puede liberar el bloque de 5 enteros. ASan solo reporta el primero, porque se detiene en la doble liberación, antes de buscar fugas:
$ g++ -Wall -Wextra -std=c++20 -g -fsanitize=address,undefined errors/shallow_assign.cpp -o build/shallow_assign
$ build/shallow_assign
=================================================================
==1347==ERROR: AddressSanitizer: attempting double-free on 0x502000000010 in thread T0:
...
SUMMARY: AddressSanitizer: double-free ../../../../src/libsanitizer/asan/asan_new_delete.cpp:155 in operator delete[](void*)
Valgrind no se detiene, así que muestra los dos. Recuerden compilar sin sanitizers para usarlo:
$ g++ -Wall -Wextra -std=c++20 -g -O0 errors/shallow_assign.cpp -o build/shallow_assign_vg
$ valgrind --leak-check=full build/shallow_assign_vg
==1891== Invalid free() / delete / delete[] / realloc()
...
==1891== 20 bytes in 1 blocks are definitely lost in loss record 1 of 1
...
==1891== by 0x1089CB: main (shallow_assign.cpp:18)
La línea 18 es IntBuffer b(5);. Esos 20 bytes son los cinco enteros que perdió b.
Error común. Escribir el destructor y olvidar la copia. Es el error de esta semana. El programa compila sin avisos y falla al final, en una llave de cierre, lejos de la línea que causó el problema. La causa no está en lo que escribieron, sino en lo que el compilador escribió por ustedes. Con la opción
-Wdeprecated-copy-dtor, que no viene incluida en-Wall -Wextra,g++sí avisa, pero no contamos con ella.
La regla de cinco
Hay cinco funciones que dicen qué pasa cuando un objeto nace a partir de otro, cuando recibe el valor de otro, o cuando muere. Se les llama funciones miembro especiales:
| Función | Firma en IntBuffer |
Cuándo corre |
|---|---|---|
| Destructor | ~IntBuffer() |
Cuando el objeto muere |
| Constructor de copia | IntBuffer(const IntBuffer& other) |
Nace un objeto copiando a otro que sigue en uso |
| Asignación de copia | operator=(const IntBuffer& other) |
Un objeto que ya existe recibe una copia |
| Constructor de movimiento | IntBuffer(IntBuffer&& other) |
Nace un objeto tomando los recursos de otro |
| Asignación de movimiento | operator=(IntBuffer&& other) |
Un objeto que ya existe toma los recursos de otro |
El signo = engaña. IntBuffer b = a; no es una asignación: b no existía, así que es el constructor de copia.
Solo b = a;, con b ya creado, es una asignación. El compilador las distingue, y ustedes también deben hacerlo.
La diferencia importa porque el constructor empieza de la nada, mientras que la asignación debe encargarse de lo que
el objeto ya tenía.
Regla de cinco (rule of five): si una clase necesita escribir una de estas cinco funciones, casi seguro necesita escribir las cinco.
¿Por qué aparecen juntas? Porque las cinco hablan de lo mismo: quién es owner del recurso, y qué pasa con él. Si el destructor libera un bloque, es porque el objeto es owner de ese bloque, y entonces copiar campo por campo ya no sirve: la copia necesitaría su propio bloque. Una clase que necesita el destructor es una clase que necesita las otras cuatro.
Qué función elige el compilador
Traced es una clase que escribe el nombre de cada función especial cuando corre. El nombre del objeto nuevo agrega
+c si nació de una copia, y +m si nació de un movimiento. El programa escribe cada línea antes de ejecutarla:
$ build/special_members
Traced a("a");
constructor a
Traced b = a;
constructor de copia, desde a
Traced c = std::move(a);
constructor de movimiento, desde a
b = c;
asignación de copia, a+c = a+m
b = std::move(c);
asignación de movimiento, a+c = a+m
Traced d = make_traced();
constructor local
fin de main
destructor local
destructor a+m
destructor a+c
destructor a
std::move y make_traced los explicaremos más adelante. Por ahora, noten que los cuatro destructores del final
corren en el orden inverso de creación, y que la asignación no creó ningún objeto nuevo.
Qué escribe el compilador por su cuenta
Lo que ustedes declaran cambia lo que el compilador genera. Estas son las reglas de C++20 para lo que nos interesa:
| Si la clase declara… | Copia implícita | Movimiento implícito |
|---|---|---|
| Ninguna de las cinco | Sí, campo por campo | Sí, campo por campo |
| Destructor | Sí, pero obsoleta (deprecated) | No existe |
| Constructor o asignación de copia | Sí la otra de copia, pero obsoleta | No existe |
| Constructor o asignación de movimiento | Borrada | No se genera la otra de movimiento |
La segunda fila es la trampa. Con un destructor declarado, no hay movimiento implícito, pero la copia sigue ahí. Lo
vemos con una clase que tiene un campo Traced, con destructor y sin él:
class OnlyMember {
public:
Traced member{"m"};
};
class WithDestructor {
public:
~WithDestructor() {}
Traced member{"m"};
};
$ build/implicit_moves
sin destructor declarado:
constructor m
constructor de movimiento, desde m
con destructor declarado:
constructor m
constructor de copia, desde m
fin de main
destructor m+c
destructor m
destructor m+m
destructor m
En las dos clases se escribió std::move(...). Sin destructor, el campo se mueve. Con destructor, el campo se copia.
Por eso una clase con solo destructor falla igual aunque intenten moverla: errors/shallow_move.cpp usa IntBuffer b = std::move(a); y da la misma doble liberación, otra vez sin ningún aviso:
$ g++ -Wall -Wextra -std=c++20 -g -fsanitize=address,undefined errors/shallow_move.cpp -o build/shallow_move
$ build/shallow_move
=================================================================
==1353==ERROR: AddressSanitizer: attempting double-free on 0x502000000010 in thread T0:
La última fila de la tabla, en cambio, sí se nota al compilar: si declaran un constructor de movimiento, la copia queda borrada, y usarla es un error.
$ g++ -Wall -Wextra -std=c++20 -g -fsanitize=address,undefined errors/move_deletes_copy.cpp -o build/move_deletes_copy
errors/move_deletes_copy.cpp:12:19: error: use of deleted function 'constexpr OnlyMoves::OnlyMoves(const OnlyMoves&)'
En C++.
= defaultpide la versión que el compilador escribiría, y= deleteprohíbe la función. Si una clase no tiene por qué copiarse, lo más simple es escribirMyClass(const MyClass&) = delete;, y el error aparece al compilar, en la línea de quien intentó copiar.
La regla de cero
Hay una salida más cómoda: no ser owner de nada de forma directa. Si los campos de una clase ya se administran solos, las cinco funciones que genera el compilador son correctas, porque copiar cada campo copia de verdad, y destruir cada campo libera de verdad. Esa es la regla de cero (rule of zero): una clase que no administra un recurso directamente no declara ninguna de las cinco.
template <typename T>
class VectorStack {
Esta pila, que veremos completa más adelante, solo tiene un std::vector como campo, y no declara ninguna función
especial. Se copia, se mueve y se destruye bien, porque std::vector ya lo hace.
Entonces, la regla práctica para el curso:
- Si su clase usa un
new, es owner de un recurso: escriban las cinco. - Si no, no escriban ninguna.
Una clase que es owner de algo debería ser owner de una sola cosa. Veamos ahora las cuatro que nos faltan.
Copiar de verdad
El programa de antes se llama errors/shallow_copy.cpp, y su IntBuffer tiene solo el destructor. A partir de aquí
usamos la versión completa, en int_buffer.h e int_buffer.cpp, que declara las cinco funciones. Su encabezado también promete que data_ es nullptr si y solo si size_ es 0.
Y el constructor respeta el primer invariante. No pide bloques vacíos:
IntBuffer::IntBuffer(std::size_t size)
: data_(size == 0 ? nullptr : new int[size]()), size_(size) {}
El constructor de copia
Lo que queremos se llama copia profunda (deep copy): un bloque nuevo, con los mismos valores.
IntBuffer::IntBuffer(const IntBuffer& other) : IntBuffer(other.size_) {
for (std::size_t i = 0; i < size_; i++) {
data_[i] = other.data_[i];
}
}
: IntBuffer(other.size_) es un constructor delegado: primero corre el otro constructor, que reserva el bloque,
y después el cuerpo copia los valores. También puede leer other.data_, aunque sea privado, porque en C++ private
es por clase, no por objeto.
Con esto, la memoria queda como debe:
La asignación, escrita de la forma ingenua
La asignación tiene más trabajo: b ya tiene un bloque, y hay que soltarlo. El primer intento casi siempre es este:
IntBuffer& operator=(const IntBuffer& other) {
delete[] data_;
data_ = new int[other.size_];
size_ = other.size_;
for (std::size_t i = 0; i < size_; i++) {
data_[i] = other.data_[i];
}
return *this;
}
Devuelve *this, el propio objeto, para que a = b = c funcione como con los enteros. Y parece correcta. En efecto,
para b = a lo es, salvo por un caso que aún no se ve. El problema aparece con la autoasignación: un objeto que
se asigna a sí mismo.
¿Quién escribiría a = a? Nadie, a propósito. Pero sí items[i] = items[j] cuando i vale lo mismo que j, o
first = second cuando dos referencias apuntan al mismo objeto. Con other y *this siendo lo mismo, sigan el
código paso a paso:
| Línea | data_ (que es también other.data_) |
Contenido |
|---|---|---|
| Antes | bloque viejo | 10 0 0 |
delete[] data_ |
colgante | se perdió |
data_ = new int[...] |
bloque nuevo | basura |
| El ciclo | bloque nuevo | basura copiada sobre sí misma |
El programa pierde sus datos. Y aquí viene algo que deben ver, porque contradice lo que uno esperaría:
$ g++ -Wall -Wextra -std=c++20 -g -fsanitize=address,undefined errors/self_assign.cpp -o build/self_assign
$ build/self_assign
antes: 10
después: -1094795586
ASan no reporta nada. El valor exacto después de la asignación depende del sistema, porque es basura, pero el
programa termina sin un solo informe. No hay acceso a memoria liberada: other.data_ ya apunta al bloque nuevo cuando
el ciclo lo lee. Es memoria válida, solo que con basura. Un sanitizer encuentra accesos inválidos, no valores
equivocados. Este error solo lo encuentra una prueba que asigne un objeto a sí mismo y revise el resultado.
Se podría parchar con if (this == &other) return *this; al inicio, y mucho código antiguo lo hace. Eso arregla la
autoasignación, pero queda el caso que dejamos pendiente. Si new lanza una excepción por falta de memoria, ya
hicimos delete[] data_. El objeto queda con un puntero colgante, y cuando corra su destructor habrá una doble
liberación.
El orden correcto es primero conseguir lo nuevo, y solo después soltar lo viejo.
Copy and swap
Copy and swap es una forma de escribir la asignación que sigue ese orden sin esfuerzo. Tiene tres líneas:
IntBuffer& IntBuffer::operator=(const IntBuffer& other) {
IntBuffer copy(other);
swap(copy);
return *this;
}
void IntBuffer::swap(IntBuffer& other) noexcept {
std::swap(data_, other.data_);
std::swap(size_, other.size_);
}
Para b = c, con b de tamaño 3 y c de tamaño 5:
![Animación en cuatro pasos de b = c en copy_demo, con b de tamaño 3 con 99, 0 y 0, y c de tamaño 5 con 7, 0, 0, 0 y 0. El programa lleva 3 new[]. Primero, copy reserva otro bloque con los valores de c, y new[] sube a 4, mientras b sigue intacto. Luego, swap intercambia data_ y size_ de b y de copy, sin mover ningún bloque. Al salir de la función, copy muere y libera el bloque viejo de b, y delete[] sube a 1.](/figures/week-02/copy-and-swap.gif)
![Animación en cuatro pasos de b = c en copy_demo, con b de tamaño 3 con 99, 0 y 0, y c de tamaño 5 con 7, 0, 0, 0 y 0. El programa lleva 3 new[]. Primero, copy reserva otro bloque con los valores de c, y new[] sube a 4, mientras b sigue intacto. Luego, swap intercambia data_ y size_ de b y de copy, sin mover ningún bloque. Al salir de la función, copy muere y libera el bloque viejo de b, y delete[] sube a 1.](/figures/week-02/copy-and-swap.dark.gif)
Observen lo que no hay: no hay ningún delete, ni ninguna copia a mano. El constructor de copia ya sabe copiar, y el
destructor ya sabe liberar. La asignación solo los combina, sin repetir código. Revisemos los dos problemas:
- Autoasignación.
copyes un bloque aparte con los mismos valores. Después delswap, el objeto tiene el bloque nuevo con los datos correctos, y el viejo se libera. Funciona sin ningúnif. - Una falla. Si no hay memoria, la excepción sale en la primera línea, cuando todavía no se ha tocado nada. El objeto queda exactamente como estaba. A esto se le llama garantía fuerte (strong guarantee): la operación termina bien, o es como si no hubiera empezado.
Lo comprobamos con el programa copy_demo.cpp, que escribe cuántos new[] y delete[] lleva todo el programa. Los
cuenta alloc_count.h, que reemplaza esos dos operadores globales por versiones con contador. El programa crea a, copia a b, cambia b, hace b = c con un c de tamaño 5, y al final b = b:
$ build/copy_demo
copia: a.at(0)=10 b.at(0)=10 | new[]=2 delete[]=0
cambio b: a.at(0)=10 b.at(0)=99 | new[]=2 delete[]=0
b = c: a.at(0)=10 b.at(0)=7 | new[]=4 delete[]=1
b.size()=5
b = b: a.at(0)=10 b.at(0)=7 | new[]=5 delete[]=2
b.size()=5
Sigamos los números. La copia reservó un bloque, y cambiar b no toca a a: la copia ya es independiente. En b = c
hubo dos new[], el de IntBuffer c(5) y el de copy, y un delete[]: el bloque viejo de b, que se fue con copy.
Y b = b conservó los datos, a cambio de un new[] y un delete[]. Ese es el precio de no tener un if: una
autoasignación, que casi nunca pasa, cuesta una copia entera. Es un buen trato.
La garantía fuerte la comprueba buffer_test.cpp, con un tipo cuya copia lanza una excepción a propósito. Después de
la falla, el destino de la asignación sigue con su tamaño y sus valores, y ASan no encuentra fugas.
En C++. Un constructor delegado también evita una fuga poco visible. Si un constructor lanza una excepción, el destructor de ese objeto no corre, porque el objeto nunca terminó de nacer. Pero si delega en otro constructor y este ya terminó, el objeto ya existe y el destructor sí corre. Por eso nuestro constructor de copia no pierde el bloque si copiar un elemento falla. Con enteros no puede fallar; con
Buffer<T>, más adelante, sí.
Mover
Copiar 1000 enteros para que el original se destruya en la línea siguiente es trabajo perdido. Pasa más de lo que parece: al devolver un resultado, al meter un temporal en un vector, al reacomodar elementos. Si el original ya no se va a usar, se le puede quitar el bloque en vez de duplicarlo.
Mover (move) es pasar los recursos de un objeto a otro, sin copiarlos, dejando al original en un estado válido.
![Animación en cuatro pasos, con dos paneles. En los dos, a tiene un bloque de 1000 enteros con 42 en el primero, y new[] vale 1. A la izquierda, IntBuffer b = a reserva otro bloque de 1000, new[] sube a 2, y se copian los enteros: quedan dos bloques, y a sigue igual. A la derecha, IntBuffer b = std::move(a) copia los dos campos, y por un momento a y b apuntan al mismo bloque. Luego, a queda con nullptr y 0, b se queda con el bloque, y new[] sigue en 1.](/figures/week-02/copiar-contra-mover.gif)
![Animación en cuatro pasos, con dos paneles. En los dos, a tiene un bloque de 1000 enteros con 42 en el primero, y new[] vale 1. A la izquierda, IntBuffer b = a reserva otro bloque de 1000, new[] sube a 2, y se copian los enteros: quedan dos bloques, y a sigue igual. A la derecha, IntBuffer b = std::move(a) copia los dos campos, y por un momento a y b apuntan al mismo bloque. Luego, a queda con nullptr y 0, b se queda con el bloque, y new[] sigue en 1.](/figures/week-02/copiar-contra-mover.dark.gif)
Mover cuesta copiar dos campos, sin importar el tamaño del bloque.
Referencias a un valor que se va
¿Cómo sabe el compilador cuándo mover? Por el tipo del argumento. En C++, una expresión es un lvalue si nombra algo
que sigue existiendo y se puede volver a usar (a, items[i]), y un rvalue si es un valor a punto de desaparecer
(un temporal como IntBuffer(4), o el resultado de x + y).
Un T&& es una referencia a rvalue: una referencia que solo se une a algo que nadie más va a usar. Con eso, las
dos versiones de cada operación se distinguen como cualquier sobrecarga:
IntBuffer(const IntBuffer& other); // recibe lvalues y rvalues: copia
IntBuffer(IntBuffer&& other); // recibe solo rvalues: se prefiere para ellos
El constructor y la asignación de movimiento
IntBuffer::IntBuffer(IntBuffer&& other) noexcept
: data_(other.data_), size_(other.size_) {
other.data_ = nullptr;
other.size_ = 0;
}
IntBuffer& IntBuffer::operator=(IntBuffer&& other) noexcept {
IntBuffer taken(std::move(other));
swap(taken);
return *this;
}
En el constructor, las dos últimas líneas son la parte esencial. Sin ellas, los dos objetos apuntarían al mismo bloque y
volvería la doble liberación. Dejar other.data_ en nullptr hace que su destructor no libere nada, porque delete[]
de un nullptr no hace nada.
La asignación sigue la misma idea de copy and swap. taken le quita todo a other. Después, el objeto intercambia con
taken, que se lleva el bloque viejo y lo libera al morir. Funciona incluso para a = std::move(a): taken le quita
todo a a, el swap se lo devuelve, y taken muere vacío. La prueba automovimiento de int_buffer_test.cpp lo revisa.
std::move no mueve nada
Este es el malentendido más común de C++. A pesar del nombre, std::move no mueve, no reserva, no copia y no cambia
nada al ejecutar. Es un cast: toma una expresión y la presenta como un rvalue. Por dentro hace
static_cast<T&&>(x), y nada más. Quien mueve es la función que se elige después, gracias a ese cast.
move_demo.cpp lo muestra con contadores. Este es el inicio, y las demás partes siguen igual:
IntBuffer a(1000);
a.at(0) = 42;
report("a creado");
IntBuffer&& same = std::move(a);
report("solo std::move(a)");
$ build/move_demo
a creado: new[]=1 delete[]=0
solo std::move(a): new[]=1 delete[]=0
same.size()=1000 a.size()=1000 a.at(0)=42
constructor de movimiento: new[]=1 delete[]=0
a.size()=0 b.size()=1000 b.at(0)=42
c creado: new[]=2 delete[]=0
asignación de movimiento: new[]=2 delete[]=1
b.size()=0 c.size()=1000 c.at(0)=42
se reutiliza b, con una copia: new[]=3 delete[]=1
b.size()=1000 b.at(0)=42
d creado, const: new[]=4 delete[]=1
std::move de un const: new[]=5 delete[]=1
d.size()=20 e.size()=20
Vamos por partes:
- Solo
std::move(a). Después deIntBuffer&& same = std::move(a);,asigue intacto, con sus 1000 elementos. Nadie lo movió.samesolo es otro nombre paraa. - Constructor de movimiento.
new[]sigue en 1: mover 1000 enteros no reservó nada.aquedó vacío, ybtiene el bloque con el 42. - Asignación de movimiento.
ctenía su propio bloque de 10.new[]no cambia, ydelete[]sube a 1: se liberó el bloque viejo dec. - Volver a usar
b. Se puede asignar a un objeto movido. La copia sí reserva:new[]sube a 3. std::movede unconst.new[]sube de 4 a 5, ydsigue con sus 20 elementos. Fue una copia. Unconst IntBuffer&&no se puede unir aIntBuffer&&, porque mover modifica el original, así que el compilador elige el constructor de copia, sin avisar. El código dice “mover”, y copia.
Idea clave.
std::move(x)significa “a partir de aquí, trátenme axcomo algo que ya no se va a usar”. Si existe una función que aproveche eso, se mueve. Si no, se copia. Nada falla ni avisa en ese caso.
Qué queda en el objeto movido
Después de mover, el original sigue existiendo: su destructor correrá de todas formas. Así que debe quedar en un estado válido, es decir, uno que al menos se pueda destruir y al que se le pueda asignar. Qué más se puede esperar de él depende de quién escribió la clase:
- Sus clases. Ustedes lo deciden, y lo escriben.
IntBufferpromete que el original queda vacío, consize() == 0, y ese estado cumple sus invariantes. Por esomove_demo.cppllama aa.check_invariants()justo después de mover. - La biblioteca estándar. El estándar solo promete válido pero no especificado (valid but unspecified): se
cumplen los invariantes del tipo, y funciona cualquier operación que no tenga precondiciones, como
size(),clear(), o asignarle un valor. Pero no se puede suponer cuál es su contenido.
std::string text = "un texto bastante largo para no caber en el objeto";
std::string other = std::move(text);
std::cout << "text.size()=" << text.size() << " other.size()=" << other.size() << "\n";
text = "de nuevo";
std::cout << text << "\n";
$ build/library_moved_from
text.size()=0 other.size()=50
de nuevo
numbers.size()=1 taken.size()=3
En este entorno, text quedó vacía. Eso no es una promesa, sino lo que hace esta versión de la biblioteca. Otra
podría dejar otra cosa. Lo correcto es asignarle un valor antes de volver a leerla, como hace el programa.
Leer un objeto movido sin revisar es un bug, aunque compile. Con IntBuffer lo detecta el assert de at, porque el
objeto tiene tamaño 0:
$ g++ -Wall -Wextra -std=c++20 -g -fsanitize=address,undefined errors/use_after_move.cpp int_buffer.cpp -o build/use_after_move
$ build/use_after_move
b.size() = 3
use_after_move: int_buffer.cpp:42: int& IntBuffer::at(std::size_t): Assertion `index < size_' failed.
Devolver un objeto local
Una duda frecuente: al devolver un objeto de una función, ¿hay que escribir std::move? No. Miren make_traced en la
salida de special_members:
Traced make_traced() {
Traced local("local");
return local;
}
Corrió un solo constructor, y ningún movimiento. El compilador construyó local directamente en el lugar de d. A esto
se le llama elisión de copia (copy elision). Para un objeto con nombre como local, el estándar la permite pero no la
exige. g++ la aplica incluso sin optimizaciones. Si no pudiera aplicarla, el return movería por su cuenta, porque el
lenguaje trata como rvalue a una local que se devuelve así.
Si agregan std::move, el resultado es peor. Ya no es la misma variable, así que la elisión no se puede aplicar, y
-Wall lo avisa:
$ g++ -Wall -Wextra -std=c++20 -g -fsanitize=address,undefined errors/pessimizing_move.cpp -o build/pessimizing_move
errors/pessimizing_move.cpp:8:21: warning: moving a local object in a return statement prevents copy elision [-Wpessimizing-move]
$ build/pessimizing_move
constructor local
constructor de movimiento, desde local
destructor local
destructor local+m
Por qué noexcept
Las dos funciones de movimiento llevan noexcept: la promesa de que no lanzan excepciones. Si una lanzara de todos modos,
el programa terminaría de inmediato. La promesa no solo documenta. Cambia lo que hace la biblioteca.
Cuando un std::vector se queda sin espacio, reserva un bloque más grande y pasa sus elementos allí, algo que estudiaremos
en la semana 4. Supongan que std::vector lleva movidos 500 de 1000 elementos, y el siguiente movimiento lanza una
excepción. Los primeros 500 ya no están en el bloque viejo, y devolverlos podría fallar también. No hay vuelta atrás, y
std::vector promete la garantía fuerte en ese caso. Por eso usa std::move_if_noexcept: mueve si el movimiento es
noexcept, y si no, copia, porque al copiar el original sigue intacto y solo hay que descartar lo nuevo. La única
excepción es un tipo que no se pueda copiar: ahí no le queda más que mover.
Lo medimos con dos clases iguales, con un contador de copias y movimientos. La única diferencia es esta palabra:
SafeMove(SafeMove&& other) noexcept : counts_(other.counts_) {
RiskyMove(RiskyMove&& other) : counts_(other.counts_) {
$ build/vector_growth
SafeMove nothrow=1 antes de crecer: copias=0 movimientos=0
SafeMove después de crecer: copias=0 movimientos=1000
RiskyMove nothrow=0 antes de crecer: copias=0 movimientos=0
RiskyMove después de crecer: copias=1000 movimientos=0
Son 1000 elementos, y con solo quitar noexcept se pasa de 1000 movimientos a 1000 copias. Por eso las funciones de
movimiento, y swap, llevan noexcept siempre que puedan cumplirlo. Las nuestras pueden, porque solo intercambian punteros
y enteros.
En C++. Existe una forma de escribir copy and swap con una sola asignación:
operator=(IntBuffer other), que recibe por valor. El parámetro se construye copiando o moviendo, según lo que le pasen, y el cuerpo solo haceswap. Es válida y se ve mucho. Pero reemplaza a las dos asignaciones. Si se escribe junto a una asignación de movimiento,b = std::move(a)no compila, porque las dos sirven por igual:
$ g++ -Wall -Wextra -std=c++20 -g -fsanitize=address,undefined errors/ambiguous_assign.cpp -o build/ambiguous_assign
errors/ambiguous_assign.cpp:20:20: error: ambiguous overload for 'operator=' (operand types are 'Both' and 'std::remove_reference<Both&>::type' {aka 'Both'})
En este curso escribimos las dos por separado, porque así se ven las cinco funciones.
Templates
IntBuffer guarda enteros. Si mañana necesitan cadenas, ¿copian la clase entera y cambian int por std::string? En C,
ya conocen la respuesta genérica: void *, tamaños en bytes y punteros a función.
typedef int (*compare_fn)(const void *, const void *);
const void *max_of(const void *items, size_t count, size_t size, compare_fn compare) {
const char *bytes = items;
const void *best = items;
for (size_t i = 1; i < count; i++) {
const void *item = bytes + i * size;
if (compare(item, best) > 0) {
best = item;
}
}
return best;
}
const int *best = max_of(numbers, 3, sizeof numbers[0], compare_int);
Funciona, y qsort está escrita así. Pero el compilador borró toda la información de tipos: confía en que ustedes pasen
la comparación correcta. Si no lo hacen, no se entera:
double prices[] = {1.5, 0.25, 7.75};
const double *top = max_of(prices, 3, sizeof prices[0], compare_int);
printf("%.2f\n", *top);
$ gcc -Wall -Wextra -std=c17 -g -fsanitize=address,undefined errors/void_wrong_compare.c -o build/void_wrong_compare
$ build/void_wrong_compare
1.50
Compila sin avisos, los sanitizers callan, y dice que el máximo de 1.5, 0.25 y 7.75 es 1.50. Además, leer un
double como int es comportamiento indefinido, así que este resultado ni siquiera está garantizado.
Funciones template
Un template es código con un hueco para un tipo. No es una función, sino una receta para generar funciones. El compilador la completa con el tipo que haga falta:
template <typename T>
const T& max_of(const std::vector<T>& items) {
assert(!items.empty());
const T* best = &items[0];
for (std::size_t i = 1; i < items.size(); i++) {
if (items[i] > *best) {
best = &items[i];
}
}
return *best;
}
std::vector<int> numbers{3, 9, 4};
std::vector<std::string> names{"Ana", "Zoila", "Beto"};
std::cout << max_of(numbers) << "\n";
std::cout << max_of(names) << "\n";
$ build/max_of
9
Zoila
template <typename T> declara el hueco. T es un nombre como cualquier otro, y se elige por costumbre. En la llamada,
el compilador deduce T del argumento: un std::vector<int> significa que T es int.
Crear una versión concreta del template se llama instanciar. Y es literal: el compilador genera una función por cada
tipo usado. Se puede comprobar en el archivo objeto, con nm, que lista lo que contiene:
$ g++ -std=c++20 -O0 -c max_of.cpp -o build/max_of.o
$ nm -C build/max_of.o | /usr/bin/grep 'max_of<int>'
0000000000000000 W int const& max_of<int>(std::vector<int, std::allocator<int> > const&)
La otra versión también está, con un nombre mucho más largo, porque el nombre real de std::string es largo:
$ nm -C build/max_of.o | /usr/bin/grep -c 'max_of<'
2
Son dos funciones distintas, compiladas por separado. La W significa que el símbolo es débil (weak): si varios archivos
generan la misma versión, el enlazador se queda con una sin reclamar.
Templates contra void *
Con el error de antes, el template no llega ni a compilar:
$ g++ -Wall -Wextra -std=c++20 -g -fsanitize=address,undefined errors/template_wrong_type.cpp -o build/template_wrong_type
errors/template_wrong_type.cpp:20:22: error: cannot convert 'const double*' to 'const int*' in initialization
void * y punteros a función (CS201) |
Templates | |
|---|---|---|
| Quién comprueba los tipos | Nadie: lo hace la disciplina de ustedes | El compilador |
| Un error de tipo | Se ve al ejecutar, si se ve | No compila |
| Copias del código | Una, para todos los tipos | Una por cada tipo usado |
| Tamaño del elemento | Se pasa a mano | Lo conoce el compilador |
| Llamar a la comparación | Una llamada por puntero | Código directo, que el compilador puede optimizar |
| Dónde va el código | Se compila una vez, en un .c |
En el encabezado |
| Los mensajes de error | Cortos, cuando existen | Largos y a veces difíciles de leer |
El template también tiene requisitos, solo que no están escritos en ninguna parte: son lo que el cuerpo use. max_of usa
>. Con un tipo que no lo tenga, el error sale al instanciar, y el mensaje apunta dentro del template:
$ g++ -Wall -Wextra -std=c++20 -g -fsanitize=address,undefined errors/no_operator.cpp -o build/no_operator
errors/no_operator.cpp: In instantiation of 'const T& max_of(const std::vector<T>&) [with T = Point]':
errors/no_operator.cpp:25:11: required from here
Estos mensajes se leen buscando dos cosas: la línea required from here, que dice qué línea suya pidió la instancia, y el
with T = ..., que dice con qué tipo.
Por qué un template va en el encabezado
Recuerden cómo se compila: cada .cpp se compila solo, sin ver los demás, y al final el enlazador junta los resultados.
Para una función común eso basta. main.cpp ve la declaración, y el cuerpo se compila aparte, una sola vez.
Con un template no alcanza, porque el compilador necesita el cuerpo y el tipo al mismo tiempo para generar la versión. Veamos qué pasa si se separa como de costumbre, con esta clase pequeña:
template <typename T>
class Box {
public:
explicit Box(T value);
const T& value() const;
private:
T value_;
};
template <typename T>
Box<T>::Box(T value) : value_(value) {}
template <typename T>
const T& Box<T>::value() const {
return value_;
}
Box<int> box(5);
std::cout << box.value() << "\n";
$ g++ -Wall -Wextra -std=c++20 -g -fsanitize=address,undefined errors/split_template/main.cpp errors/split_template/box.cpp -o build/split_template
/work/code/week-02/errors/split_template/main.cpp:7:(.text+0x94): undefined reference to `Box<int>::Box(int)'
/usr/bin/ld: /work/code/week-02/errors/split_template/main.cpp:8:(.text+0x180): undefined reference to `Box<int>::value() const'
collect2: error: ld returned 1 exit status
Cada archivo compiló bien, y lo que falló fue el enlace. Sigan el razonamiento:
- Al compilar
main.cpp, el compilador sabe que hace faltaBox<int>, pero solo ve las declaraciones debox.h. Deja un pendiente para el enlazador. - Al compilar
box.cpp, ve los cuerpos, pero nadie le pidióBox<int>. Un template que nadie usa no genera nada. - El enlazador busca
Box<int>::Box(int), y no existe en ninguna parte.
La solución normal es poner el cuerpo en el encabezado, para que todo .cpp que use el template lo vea. Por eso IntBuffer
tiene int_buffer.h e int_buffer.cpp, y Buffer<T> solo tiene buffer.h. Y por eso en <vector> está todo el código
de std::vector. Existe otra salida, la instanciación explícita, que obliga a generar ciertos tipos desde un .cpp, pero
exige conocer de antemano todos los tipos, y no la usaremos.
Clases template
Una clase también puede ser template. Buffer<T> es IntBuffer con un hueco. Las cinco funciones cambian poco:
template <typename T>
class Buffer {
public:
explicit Buffer(std::size_t size) : data_(size == 0 ? nullptr : new T[size]()), size_(size) {}
~Buffer() {
delete[] data_;
}
Buffer(const Buffer& other) : Buffer(other.size_) {
for (std::size_t i = 0; i < size_; i++) {
data_[i] = other.data_[i];
}
}
Buffer& operator=(const Buffer& other) {
Buffer copy(other);
swap(copy);
return *this;
}
Con las clases, el tipo se escribe al declarar: Buffer<int>, Buffer<std::string>. Son dos tipos sin relación, y un
Buffer<int> no se puede asignar a un Buffer<std::string>. Dentro de la clase, Buffer a secas significa Buffer<T>.
Los métodos de una clase template solo se instancian si alguien los usa, así que un método que no compilaría con cierto
T no estorba mientras nadie lo llame.
Qué le exigimos a T
Mirando el cuerpo, Buffer<T> necesita dos cosas de T:
- Un constructor sin argumentos, porque
new T[size]()construye todos los elementos al reservar. - Que se pueda asignar, porque la copia hace
data_[i] = other.data_[i].
Lo primero es una limitación seria, y conviene decirlo sin rodeos: una clase que no se pueda construir sin argumentos
no se puede guardar en Buffer<T>. Y como la pila del laboratorio también pide su arreglo con new T[capacity], tiene la
misma limitación: por ahora, T debe tener un constructor sin argumentos. Por ejemplo, con un Student cuyo único
constructor recibe un nombre:
$ g++ -Wall -Wextra -std=c++20 -g -fsanitize=address,undefined errors/no_default.cpp -o build/no_default
errors/no_default.cpp:15:31: required from here
errors/../buffer.h:16:69: error: no matching function for call to 'Student::Student()'
Además, new T[capacity] construye todos los elementos de una vez, aunque aún no se usen. Lo pueden ver contando:
$ build/array_constructs
después de new[]: construidos=1000 destruidos=0
después de delete[]: construidos=1000 destruidos=1000
Para int no cuesta nada, pero con T caro, una pila de capacidad 1000 corre 1000 constructores aunque solo guarde un
elemento.
std::vector no tiene esta limitación: no_default_vector.cpp guarda ese mismo Student con push_back, sin problema. Lo
logra porque separa reservar memoria de construir objetos en ella, y construye cada elemento recién cuando hace falta. Es una
técnica más delicada, que aquí no usamos. Por ahora escogemos lo simple, y sabemos lo que cuesta.
Tipo abstracto e implementación
Hasta ahora hablamos de cómo está hecha una clase. Cambiemos de pregunta: ¿qué ofrece?
Un tipo abstracto de datos (abstract data type) es un conjunto de operaciones, junto con lo que cada una promete, sin decir cómo se logra. La implementación es una forma concreta de cumplir esas promesas.
Es la separación que conocen de CS201 entre interfaz e implementación, llevada a un nivel más alto. Y el contrato (contract) es esa promesa, escrita con precisión: qué debe cumplirse antes de llamar a una operación (precondición), y qué será cierto después (poscondición).
La pila
La pila (stack) es el tipo abstracto más simple que hay. Ya conocen una, la de los marcos de llamada. Guarda elementos, y solo se puede acceder al último que entró: el último en entrar es el primero en salir.
| Operación | Precondición | Qué hace |
|---|---|---|
push(x) |
Hay espacio (en una pila de capacidad fija) | Agrega x arriba |
pop() |
No está vacía | Saca el elemento de arriba |
top() |
No está vacía | Devuelve el elemento de arriba, sin sacarlo |
empty() |
Ninguna | Dice si no tiene elementos |
Unas propiedades escritas con precisión son el contrato. Para toda pila s y valor x:
- Después de
s.push(x),s.top()devuelvexys.empty()es falso. s.push(x)seguido des.pop()deja ascomo estaba.
Noten lo que no dice: ni arreglos, ni punteros, ni tope. Por eso, la pregunta de qué es parte de qué tiene respuesta clara:
| ¿Tipo abstracto o implementación? | |
|---|---|
push |
Tipo abstracto: es una operación que se ofrece |
| “El último en entrar es el primero en salir” | Tipo abstracto: es la promesa |
| El arreglo | Implementación: otra pila podría usar una lista enlazada |
| El tope | Implementación: una lista enlazada no lo tiene como índice |
Un uso: la notación postfija
Lo valioso de un tipo abstracto es que quien lo usa no necesita saber cómo está hecho. En notación postfija el operador va
después de sus operandos: 3 4 + 2 * significa (3 + 4) * 2, sin paréntesis. Se evalúa con una pila: un número se apila, y un
operador saca dos, opera y apila el resultado.
int eval_postfix(const std::vector<std::string>& tokens) {
VectorStack<int> values;
for (const std::string& token : tokens) {
if (token == "+" || token == "-" || token == "*") {
assert(values.size() >= 2);
int right = values.top();
values.pop();
int left = values.top();
values.pop();
| Token | Acción | Pila, con el tope a la derecha |
|---|---|---|
3 |
Apila | 3 |
4 |
Apila | 3 4 |
+ |
Saca 4 y 3, apila 7 | 7 |
2 |
Apila | 7 2 |
* |
Saca 2 y 7, apila 14 | 14 |
$ build/postfix
14
4
El segundo resultado es de 10 2 3 * -, que es 10 - 2 * 3. Esa función nunca menciona un arreglo, ni un std::vector.
Dos implementaciones del mismo tipo
La forma más directa de implementar una pila es sobre std::vector: push llama a push_back, pop a pop_back y top a back. Es la VectorStack de vector_stack.h, de unas pocas líneas.
Y aquí tienen la otra, la del laboratorio: un arreglo y un tope. Se reserva un arreglo de capacidad casillas, y el tope
dice cuántas se están usando:
![Animación en siete pasos de una pila en un arreglo de capacidad 3. Al inicio, tope es 0. push(3) deja 3 en datos[0], y tope en 1. push(8) deja 8 en datos[1], y tope en 2. top() devuelve 8. pop() baja tope a 1, y el 8 sigue en el arreglo, fuera de la pila. top() devuelve 3. push(5) escribe sobre el 8, y tope vuelve a 2. Una marca señala siempre datos[tope - 1], el elemento de arriba.](/figures/week-02/pila-arreglo-y-tope.gif)
![Animación en siete pasos de una pila en un arreglo de capacidad 3. Al inicio, tope es 0. push(3) deja 3 en datos[0], y tope en 1. push(8) deja 8 en datos[1], y tope en 2. top() devuelve 8. pop() baja tope a 1, y el 8 sigue en el arreglo, fuera de la pila. top() devuelve 3. push(5) escribe sobre el 8, y tope vuelve a 2. Una marca señala siempre datos[tope - 1], el elemento de arriba.](/figures/week-02/pila-arreglo-y-tope.dark.gif)
Las operaciones, en pseudocódigo:
algoritmo push(datos, tope, valor)
datos[tope] ← valor
devolver tope + 1
fin algoritmo
algoritmo pop(tope)
devolver tope - 1
fin algoritmo
algoritmo top(datos, tope)
devolver datos[tope - 1]
fin algoritmo
Sigamos unas operaciones sobre un arreglo de capacidad 3:
| Operación | datos |
tope |
Devuelve |
|---|---|---|---|
| (inicio) | ? ? ? |
0 | |
push(3) |
3 ? ? |
1 | |
push(8) |
3 8 ? |
2 | |
top() |
3 8 ? |
2 | 8 |
pop() |
3 8 ? |
1 | |
top() |
3 8 ? |
1 | 3 |
push(5) |
3 5 ? |
2 |
Fíjense en la quinta fila. pop no borra nada: el 8 sigue en el arreglo, y simplemente ya no cuenta como parte de la pila.
En Stack<T>, eso tiene una consecuencia concreta: el elemento sacado sigue vivo como objeto, hasta que otro push lo reemplace o
la pila se destruya. Si es un std::string grande, su memoria sigue ocupada todo ese tiempo.
Tampoco crece. Si se llena, se llenó: push tiene como precondición que haya espacio. El crecimiento, que es lo que hace el
ArrayStack de la lectura, tiene su propio costo y su propio análisis, y llega en la semana 4.
Esta es la interfaz que escribirán en el laboratorio. Es la de lab/stack.h, que trae también size y capacity:
void push(T) { // Requiere !full().
void pop() { // Requiere !empty().
T& top() { // Requiere !empty().
Las dos implementaciones cumplen el mismo contrato. Por eso, en vector_stack_test.cpp, las pruebas están escritas como
templates que reciben el tipo de la pila, y no saben cuál es.
Idea clave. El tipo abstracto dice qué. La implementación dice cómo. Un programa escrito contra el qué no se entera si cambia el cómo. En este curso veremos varias implementaciones del mismo tipo, y la diferencia entre ellas será el costo de cada operación.
En la biblioteca.
std::stackes la pila de la biblioteca, en<stack>, conpush,pop,top,emptyysize. Por defecto usa unstd::dequepor dentro, pero se puede cambiar, y se ve igual desde afuera. Supoptampoco devuelve el elemento: si lo devolviera por valor, y copiarlo lanzara una excepción, el elemento ya se habría sacado y se perdería. Por esotopypopestán separadas, en esa biblioteca y en nuestra pila. No necesita unTcon constructor sin argumentos.
Invariantes
Un contrato habla desde afuera. Desde adentro, lo que mantiene todo en pie son los invariantes.
Un invariante (invariant) de una estructura es una condición sobre sus campos que se cumple siempre entre dos operaciones: al terminar el constructor, y al terminar cada método.
“Entre dos operaciones” es importante. Dentro de un método, el invariante se puede romper por un momento, como en el swap,
donde un objeto tiene el data_ nuevo y el size_ viejo. Lo que importa es que esté restablecido antes de que nadie más pueda
mirar. Por eso los campos son privados: si cualquiera pudiera cambiar size_, no se podría prometer nada.
Los invariantes de una pila sobre un arreglo son estos, y son los que trae lab/stack.h:
// 1. size_ <= capacity_.
// 2. data_ == nullptr si y solo si capacity_ == 0.
// 3. Los elementos están en data_[0], ..., data_[size_ - 1]. El tope es data_[size_ - 1].
Si se cumplen, todo método puede confiar en ellos. pop no necesita revisar si size_ tiene sentido: el invariante se lo
garantiza. El razonamiento es local: cada método supone el invariante al entrar, y debe dejarlo cierto al salir.
Estos invariantes deben cumplirse también en un objeto movido. Por eso el constructor de movimiento no puede dejar size_
como estaba. Si solo pusiera data_ en nullptr, un size_ mayor que cero rompería el primer invariante en la siguiente
comprobación. Y hay una precondición que recordar: un objeto movido tiene capacidad 0, así que push sobre él viola
!full(). Hay que asignarle algo antes.
check_invariants
Una regla de este curso: cada estructura declara sus invariantes en un comentario y los comprueba en un método
check_invariants, que usan las pruebas.
Las condiciones de IntBuffer son pocas. Para ver para qué sirve de verdad, tomemos una clase cuyo invariante sí cuesta
comprobar: una lista de enteros que siempre está ordenada. Es de sorted_ints.h, y como solo tiene un std::vector, sigue la regla de cero:
// Invariante: items_[i - 1] <= items_[i] para todo i válido.
class SortedInts {
public:
void insert(int value) {
items_.push_back(value);
std::size_t i = items_.size() - 1;
while (i > 0 && items_[i - 1] > value) {
items_[i] = items_[i - 1];
i--;
}
items_[i] = value;
}
void check_invariants() const {
for (std::size_t i = 1; i < items_.size(); i++) {
assert(items_[i - 1] <= items_[i]);
}
}
La prueba, en sorted_ints_test.cpp, hace operaciones al azar y llama a check_invariants después de cada una. Usa una semilla
fija, así que repite siempre la misma secuencia, y un error que aparezca se puede volver a ver.
Ahora introduzcamos un bug. En errors/broken_sorted.cpp, el ciclo se detiene una casilla antes:
while (i > 1 && items_[i - 1] > value) {
La inserción de 5 funciona. La de 3 deja 5 3, y el invariante lo detecta de inmediato, justo después de la operación culpable:
$ g++ -Wall -Wextra -std=c++20 -g -fsanitize=address,undefined errors/broken_sorted.cpp -o build/broken_sorted
$ build/broken_sorted
broken_sorted: errors/broken_sorted.cpp:25: void SortedInts::check_invariants() const: Assertion `items_[i - 1] <= items_[i]' failed.
Sin check_invariants, ese error aparecería más tarde, quizá en una búsqueda que dé un resultado equivocado, y lejos de la causa.
Por eso conviene el método. Tres precisiones:
check_invariantsno reemplaza a las pruebas. Una lista ordenada con el valor equivocado cumple su invariante. Por eso las pruebas también comparan contra una referencia de la biblioteca estándar.- Puede costar.
check_invariantsdeSortedIntsrecorre toda la lista. Se llama desde las pruebas, no desdeinsert. assertse puede apagar. Compilando con-DNDEBUGdesaparece, y por eso no sirve para validar datos del usuario. Sirve para atrapar errores del programador: un invariante roto o una precondición violada, comopopsobre una pila vacía.
El código de esta semana
| Archivo | Qué muestra |
|---|---|
new_basics.cpp, scope_trace.cpp, array_constructs.cpp, owner_in_c.c |
new, delete, destructores, y el owner en C |
int_buffer.h, int_buffer.cpp |
Una clase que es owner de su memoria, con la regla de cinco |
traced.h, special_members.cpp, implicit_moves.cpp |
Qué función especial se elige |
copy_demo.cpp, move_demo.cpp, alloc_count.h |
Copiar y mover, con reservas contadas |
vector_growth.cpp, library_moved_from.cpp |
noexcept, y lo que queda después de mover |
max_void.c, max_of.cpp, buffer.h, no_default_vector.cpp |
Genéricos en C y en C++ |
vector_stack.h, postfix.cpp, sorted_ints.h |
Tipo abstracto e invariantes |
int_buffer_test.cpp, buffer_test.cpp, sorted_ints_test.cpp, vector_stack_test.cpp |
Pruebas, con check.h |
errors/ |
Programas con errores a propósito |
Las pruebas de C++ usan assert y una pequeña ayuda, run_test, que escribe el nombre de cada prueba terminada:
$ build/int_buffer_test
ok copia independiente
ok asignación de copia reserva una vez
ok autoasignación
ok mover no reserva y deja vacío
ok automovimiento
ok un objeto movido se puede volver a usar
ok reservas y liberaciones se emparejan
make test corre todas, compila los programas de errors/, y comprueba que cada comando y cada salida de este capítulo sigan
siendo ciertos. Debe terminar con OK.
El laboratorio
La guía de la semana tiene los pasos. El código inicial está en lab/, con funciones que escriben falta implementar y detienen el programa. Por eso, al principio, make test-lab falla. Tiene una clase de prueba por paso, en orden, así
que vayan viendo cómo pasa cada una.
- Pasos 1 a 4.
IntStack, enlab/int_stack.hylab/int_stack.cpp. Después del paso 1,make copy-democorre la prueba del paso 2 y les muestra el informe de ASan. Lo que aprendieron conIntBufferse aplica, con una diferencia: ahora hay dos números,size_ycapacity_, y deben decidir qué copiar de cada uno. - Paso 4. La prueba cuenta los
new[]con la misma técnica dealloc_count.h. Antes de que escriban el movimiento, esa prueba falla, porquestd::movetermina copiando. - Paso 5.
Stack<T>, enlab/stack.h. Recuerden queTdebe tener un constructor sin argumentos. - Paso 6. El verificador de
lab/brackets.cpp. El comentario delab/brackets.hda los mensajes exactos. Los comentarios y las cadenas ya vienen blanqueados porblank_comments_and_literals, delab/code_text.cpp, así que solo se ocupan de la pila.
Resumen
newreserva y construye.deletedestruye y libera. Con arreglos,new[]ydelete[], sin mezclarlos.- El destructor corre solo, al salir del alcance, en orden inverso de creación.
- RAII: adquirir en el constructor y liberar en el destructor. El owner ahora está en el tipo.
- La copia por defecto copia campos. Para un puntero, eso es una copia superficial: dos owners del mismo bloque, doble liberación, y ningún aviso al compilar.
- Regla de cinco: destructor, constructor de copia, asignación de copia, constructor de movimiento y asignación de movimiento. Aparecen juntas porque las cinco tratan del mismo recurso. Con un destructor declarado, no hay movimiento implícito.
- Regla de cero: una clase que no administra un recurso directamente no declara ninguna.
- Copy and swap: copiar primero, intercambiar después, y dejar que el destructor libere lo viejo. Soporta la autoasignación y da la garantía fuerte. La versión ingenua pierde los datos, y ASan no lo nota.
- Mover toma los recursos sin reservar.
std::movees solo un cast: si no hay función de movimiento que aplique, como con unconst, se copia sin avisar. - Un objeto movido queda válido. En sus clases, ustedes dicen en qué estado. En la biblioteca, es válido pero no especificado.
noexcepten el movimiento decide sistd::vectormueve o copia al crecer.- Un template genera una versión por tipo, por eso su cuerpo va en el encabezado. Da la comprobación de tipos que
void *no da, a cambio de una copia del código por cada tipo. new T[n]exige un constructor sin argumentos y construye losnelementos al reservar. Es una limitación nuestra por ahora.- Un tipo abstracto dice qué operaciones hay y qué prometen. La implementación dice cómo. Pila:
push,pop,top,empty. - Un invariante es lo que siempre se cumple entre dos operaciones.
check_invariantslo comprueba en las pruebas.
Ejercicios
Los ejercicios de programación se resuelven dentro del entorno del curso, compilando con -Wall -Wextra -std=c++20 -g -fsanitize=address,undefined.
-
Dibujen la memoria, antes de que cierre la llave de
main, para este código, con laIntBufferque solo tiene destructor. ¿Cuántos bloques hay? ¿Cuántos owners cree tener cada uno? ¿Qué reporta ASan?IntBuffer a(2); IntBuffer b(a); IntBuffer c(4); c = a; -
Para cada línea, digan qué función especial corre, si es que corre alguna. Luego compruébenlo con la clase
Traced.Traced a("a"); Traced b(a); Traced c = b; c = a; Traced d = std::move(c); const Traced e("e"); Traced f = std::move(e); Traced& g = a; -
Clasifiquen como parte del tipo abstracto o de la implementación:
top, unstd::vectorusado por dentro, “poprequiere que la pila no esté vacía”,capacity_, “después depush(x),top()devuelvex”. -
Sigan con una tabla la evaluación de
5 1 2 + 4 * + 3 -con la pila depostfix.cpp. Comprueben el resultado agregando la llamada al programa. -
Escriban el método
void resize(std::size_t new_size)deIntBuffer. Debe conservar los primeros valores, rellenar con ceros lo nuevo, y dar la garantía fuerte. Escriban una prueba que usearray_news()para contar las reservas. -
Escriban la función template
count_equal(const std::vector<T>& items, const T& value). Pruébenla coninty constd::string. Después pruébenla con unstructsin==, y encuentren en el mensaje de error la línea suya y el tipo. -
Agreguen a
errors/shallow_copy.cppel constructor de copia, pero no la asignación. Escriban unmainque todavía falle. ¿Qué reporta ASan? Esta vez-Wextrasí da un aviso. ¿Cuál, y por qué no lo daba cuando la clase solo tenía destructor? -
Una clase
LineLogtiene dos campos: unstd::stringcon su nombre y unstd::vector<std::string>con sus líneas. ¿Cuántas de las cinco funciones especiales debe escribir? Escriban la clase, y comprueben con una prueba que se puede copiar y mover. -
Quiten
noexceptdel constructor de movimiento deSafeMove, envector_growth.cpp, y digan qué cambia. Después, enRiskyMove, borren el constructor de copia con= delete, y corran de nuevo. ¿Qué hace ahorastd::vector, y por qué ya no da la garantía fuerte? -
Escriban la clase
IntRange, que guarda dos enteroslowyhigh, conlow <= highcomo invariante, y los métodosshift(int d),widen(int d)ycheck_invariants. ¿Qué valores deddebe rechazarwiden? Escriban la precondición conassert. -
Alguien propone escribir la pila con un
Buffer<T>y unstd::size_t size_, sin declarar ninguna función especial, siguiendo la regla de cero. Copiar funciona bien. Pero ¿qué pasa consize_en el objeto original después de mover? ¿Qué invariante se rompe? Escriban una prueba que lo demuestre, y digan cómo lo arreglarían. -
Desafío. La asignación de movimiento por
swaplibera el bloque viejo al morirtaken. Otra forma común es intercambiar directamente conother, sintaken, y dejar queotherse lleve el bloque viejo. Escríbanla. ¿Cumple lo que prometeIntBuffersobre el estado del objeto movido? ¿Cuándo se libera el bloque viejo? Decidan cuál prefieren y justifiquen.