Enlace Patrocinado
Debe ser porque no has intentado implementarlas en Rust. Ahora mismo aprovecho la cuarentena para aprender este lenguaje, y una de las primeras cosas que intento hacer es esa estructura de datos.
Implementar una lista enlazada (que sea safe) en Rust es algo complicado: uno parece estarse peleando todo el tiempo con el borrow checker y los lifetimes que aún no le he agarrado del todo la onda.
Crear una lista simplemente enlazada en C o C++ es algo trivial, pero en Rust es una pequeña pesadilla.
Esta es una lista simplemente enlazada en Rust
con algunos métodos para demo:
Enlace Patrocinado
- // Lista 'simplemente' enlazada en Rust
- // type alias por conveniencia
- type Link<T> = Option<Box<Node<T>>>;
- use std::fmt;
- use std::mem::replace as exchange;
- // Single link list
- struct SingleList<T>
- where T: std::fmt::Display, {
- head: Link<T>,
- size: usize,
- }
- // Nodo generico
- struct Node<T> {
- elem: T,
- next: Link<T>,
- }
- // implementacion
- // ... resto del código en el enlace
Lo primero que voy a comentar el el tipo de dato
Node
:
- struct Node<T> {
- elem: T,
- next: Link<T>,
- }
¿Qué es
Link<T>
? Por lo general esperamos que el campo next sea algo asi como Node<T>*
, pero en Rust (safe) no se manejan punteros de esa manera; además el lenguaje necesitaría saber el tamaño de un Node<T>
y como haría entonces si uno de sus miembros es del mismo tipo que estoy definiendo? No es posible. Sin embargo, el tamaño de un Box<Node<T>>
si es conocido. Link<T>
es entonces:
- type Link<T> = Option<Box<Node<T>>>;
Es decir un alias para
Option<Box<Node<T>>>
. Option
es necesario porque al no existir (null, NULL, nullptr
) y next
podría o no refererirse a un siguiente nodo. Como crearé los nodos en la heap entonces debo envolverlos en Box<T>
que es un managed pointer parecido a std::unique_ptr
en C++.El keyword
type
es análogo a typedef
en C.Con mi nodo definido creamos la lista propiamente:
- struct SingleList<T>{
- head: Link<T>,
- size: usize,
- }
Solo contiene dos miembros head “que es de tipo
Node<T>
” y el miembro size
que es el tamaño de la lista. La funcionalidad de la lista (sus métodos) los implemento en en un bloque impl
y son funciones asociadas a el estructura de dato:
- impl<T> SingleList<T> {
- // nueva lista vacia
- pub fn new() -> Self {
- Self{head: None, size: 0}
- }
- // check
- pub fn is_empty(&self) -> bool{
- self.size==0
- }
- // ... más funciones
- }
Noten la diferencia entre las funciones
new
y is_empty
. La primera no toma ningún argumentos y esto es porque su labor es crear una nueva lista; la segunda toma un argumento por referencia (shared reference o immutable reference como también se les refiere). self
(sí como en Python) se refiere a la instancia a la que está asociada la función (is_empty
en este caso). La notación del argumento de la última función es un shorthand para: is_empty(self: &SingleList<T>)
pero dentro del bloque impl
como primer argumento self
es especial y también lo es Self
que es el tipo de dato de self, es decir SingleList<T>
, o sea que también podría escribir is_empy(self: &Self)
. Pero esos son detalles, veamos como implementar algunas otrasde las funciones propias de una lista.Como es una lista simplemente enlazada lo más fácil de hacer es agregar elementos a la cabeza de la lista:
- pub fn push_front(&mut self, elem: T){
- let ref mut head = exchange(&mut self.head, None);
- // creo nuevo node
- let new_node = match head {
- Some(ref mut _node) =>{
- // creo nuevo nodo apuntando 'next'
- // a la antigua head
- Some(Box::new(Node{elem, next: head.take()}));},
- None =>
- // next es None
- Some(Box::new(Node{elem, next: None}))
- }
- // actualizo
- self.head = new_node;
- self.size += 1;
- }
Esta vez como agregar un elemento en una lista requiere mutar la lista, el argumento self adquiere otra notación
&mut self
que es una referencia exclusiva en Rust. Exclusiva como que solo a través de ella puedo referirme al argumento, por ello en la línea dos la intercambio y tomo ownership en head
. Sobre la cual hago un match
en la siguiente línea y si no es None
creo un nuevo nodo con el elem
y el compo next
apuntando/refiriendose a head
(head.take
es un shortcut para exchange(&mut head, None)
y exchange es un alias para std::mem::replace
.Está escrita así para de alguna forma ir paso a paso pero si ven atentamente se puede reescribir muy facilmente:
- pub fn push_front(&mut self, elem: T){
- self.head = Some(Box::new(Node{
- elem,
- next: self.head.take()}));
- self.size += 1;
- }
Aunque no es común en una lista simplemente enlazada le agregue un push_back para añadir un elemento al final de la lista con la counterpart a
push_front
y notar la diferencia:
- pub fn push_back(&mut self, elem: T) {
- let mut it: &mut Link<T> = &mut self.head;
- // encontrar el final
- while let Some(ref mut node) = it {
- it = &mut node.next;
- }
- // aquí ya `it` es None.
- *it = Some(Box::new(Node{
- elem,
- next: None
- }));
- self.size += 1;
- }
Nota: este método podría optimizarse si mantengo una referencia al último nodo.
Eliminar un elemento del principio/cabeza de la lista igual es muy fácil:
- // remove (primer elemento)
- pub fn pop_front(&mut self) -> Option<T> {
- match exchange(&mut self.head, None) {
- Some(node) => {
- self.size -= 1;
- self.head = node.next;
- Some(node.elem)
- },
- None => None
- }
- }
Que puedo igualmente simplificar más
- // remove (primer elemento)
- pub fn pop_front(&mut self) -> Option<T> {
- self.head.take().map(|node| {
- self.size -= 1;
- self.head = node.next;
- node.elem
- })
- }
PERO la que viene a continuación
pop_back
para eliminar un elemento del final me tuvo varias horas tratando de hallar la disposición correcta de las referencias, etc.:Honestamente NO quiero tener que explicarla, pero comparen con las demás y, por ejemplo, una función para remover un elemento previo a otro determinado se puede sacar muy fácilmente de aquí y no la incluí aquí.
Los demás métodos de la lista son para iterar, imprimir y destrucción de la lista cosa que hace Rust automáticamente pero que es necesario en este caso para no desbordar la pila de agregarse demasiados elementos a la lista ya que la eliminación de los nodos se hace de forma recursiva. Para romper la recursión:
- // Dtor
- impl<T> Drop for SingleList<T> {
- fn drop(&mut self) {
- let mut it = self.head.take();
- while let Some(ref mut n) = it {
- it = n.next.take(); // "nullify" el miembro next aquí
- // rompe la recursion
- }
- println!("Dropped!");
- }
- }
No sé Uds. pero no me parece ahora que en C o C++ sea más complicado. ¿Puedo convertir esta lista a una doblemente enlazada agregando un miembro
prev
y tail
a Node
y SingleList
respectivamente? Noooo… entre otras cosas para una lista doblemente enlazada ya no podría usar Box
sino Rc
(std::rc::Rc
) y como en algún momento podría requerirse mutar los elementos en la lista y Rc no me lo permitiría, tendría que además envolver Node<T>
en un RefCell
(std::cell::RefCell
), etc.¿Qué hay acerca de la optimisation para
push_back
de la que hablé en la nota arriba? Dado que las referencias en Rust son reasignables pensé (naively) que lo podía lograr fácilmente… me equivoqué. Intenté:
- struct Node<T> {
- elem: T,
- next: Link<T>,
- last: &Link<T>, // siempre apunta al último nodo
- }
y luego modificaría:
- pub fn push_front(&mut self, elem: T){
- let ref mut head = exchange(&mut self.head, None);
- // creo nuevo node
- let new_node = match head {
- Some(ref mut _node) =>{
- // creo nuevo nodo apuntando 'next'
- // a la antigua head
- Some(Box::new(Node{elem, next: head.take()}));},
- None => {
- // next es None
- let new_node =Some(Box::new(Node{elem, next: None}))
- // como es el único elemento
- // `head` y `last` se refieren al mismo nodo
- self.last = new_node;
- new_node },
- }
- // actualizo
- self.head = new_node;
- self.size += 1;
- }
Eso, por su puesto, no funciona. Primero antes de guardar una referencia en un
struct
debo asociar un lifetime
:
- struct Node<'a, T> {
- elem: T,
- next: Link<T>,
- last: &'a Link<T>, // siempre apunta al último nodo
- }
Solo hasta ahí todo bien, cuando intento modificar
push_front
y demás métodos donde manipulo self.tail fracaso. No hay manera tal y como esta escrita la lista. Debería utilizar std::rc::Rc
si quiero que tener dos (o más) referencias a un mismo nodo (podría ahondar más en esto en una próxima actualización).No sería justo cuando hablamos de comparaciones si no incluyo una versión en C (o C++). Hagamos la lista en C, tal como la versión en Rust debe ser genérica y además incluir la optimisation que no pude lograr, esta vez, en Rust:
Codigo (Rust) live: [Wandbox]三へ( へ՞ਊ ՞)へ ハッハ
Links:
-
Iterator(trait)
-
Box(pointer)
-
Display(trait)
-
std::ops::Drop – Rust(trait)
- Learn Rust with Entirely too many Lists
- std::forward_list – cppreference.com
Ahora trataré de convencerlos de que en C no tiene porque ser extremadamente complicado. Contando solo con los pocos mecanismos de abstracción de C nuestra lista debe ser «genérica» y extensible.
TODA mi lista la baso en esta estructura:
- typedef struct link_base link_base;
- struct link_base
- {
- link_base *next;
- };
- typedef struct basic_list basic_list;
- struct basic_list
- {
- link_base *b4_head; // b4_head->next es `head`
- link_base *last;
- int length;
- };
- bool is_empty(basic_list *);
- // elementos agregados a la lista
- int length(basic_list *);
- // las operaciones 'append/add/push_back/prepend/push_front' etc
- // las defino sobre esta función
- link_base *insert_after(basic_list *, link_base *, link_base *);
- // las operaciones 'erase/pop/pop_front/pop_back/insert' etc las
- // defino sobre esta función
- link_base *remove_after(basic_list *, link_base *);
Nota que en ninguna parte se menciona el tipo de dato de la lista (también nada es
void *
). Este es pues mi lista abstracta.La implementación de
insert_after
y remove_after
:
- bool is_empty(basic_list *lst)
- {
- return lst->b4_head->next == lst->last
- && lst->length == 0;
- }
- int length(basic_list *lst){ return lst->length; }
- link_base *insert_after(basic_list *lst, link_base *wh, link_base *elnk)
- {
- if(wh==0){ // agregamos al principio
- link_base **head = &((link_base *)lst->b4_head)->next;
- if(*head){
- elnk->next = *head;
- *head = elnk;
- }
- else {
- *head = lst->last = elnk;
- }
- }
- else {
- elnk->next = wh->next;
- wh->next = elnk;
- if(wh == lst->last)
- lst->last = elnk;
- }
- ++lst->length;
- return elnk;
- }
- link_base *remove_after(basic_list *lst, link_base *wh)
- {
- link_base *it = wh? wh->next: lst->b4_head->next;
- if(wh == 0){
- int steps = lst->length - 1;
- while(--steps)
- it = it->next;
- lst->last = it;
- }
- else {
- wh->next = it->next;
- }
- it->next = 0;
- --lst->length;
- return it;
- }
Las listas enlazadas pueden ser complicadas en C si:
- No comprendes bien la estructura de datos, o
- No comprendes bien punteros.
Yo suelo extenderme hablando de punteros y ya la respuesta esta bastante larga, solo comentaré acerca de las estrategias en la implementación de ambas funciones.
insert_after
recibe la lista a modifica, un puntero que indica donde insertar el nuevo nodo y el nuevo nodo que contiene el elemento. La idea aquí es que, por tratarse de una lista sencillamente enlazada, el segundo puntero (wh
) sea el elemento previo; esto es, el nuevo nodo será wh->next
. Por convención si wh == 0
indica que insertemos al principio y esto me permite implementar operaciones (push_front
/prepend
/etc). Luego un push_back
lo implemento pasando como segundo puntero el campo last
de la lista. Este campo es un puntero al último elemento de la lista, es la referencia que no pude agregar en la implementación con Rust. Entonces el nuevo elemento quedará en last->next
. Las operaciones en insert_after
son constantes. remove_after utiliza una estrategia similar, el segundo puntero me indica el elemento previo a eliminar. Nota que en la lista hay un campo b4_head
y que head propiamente es b4_head->next
. Todo esto para seguir la estrategia que utilizan las operaciones insert/remove_after
.Ahora bien, como creo una lista concreta? Hay varias maneras, comencemos con una lista para gestionar caracteres (
char
s):
- struct char_link
- {
- link_base base_;
- char data;
- };
- struct char_list
- {
- struct
- {
- struct char_link *b4_head;
- struct char_link *last;
- int length;
- };
- };
Nota el campo
base_
de tipo link_base
en la estructura char_link
. Esto me permite tratar un puntero a char_link
como uno a link_base
. Luego char_list
es básicamente un basic_list
salvo que los campos b4_head
y last son de tipo char_link
* pero como estos son convertible a link_base
entonces char_list
es un basic_list
.Eso es lo que debo hacer para una lista de cualquier tipo de dato, cambio el campo data con el tipo de dato apropiado y defino la lista. Ejemplo, para una lista de tipo con elementos de tipo
int
:
- struct int_link
- {
- link_base base_;
- int data;
- };
- struct int_list
- {
- struct
- {
- struct int_link *b4_head;
- struct int_link *last;
- int length;
- };
- };
Ahora como ejemplo implementemos las funciones que definen una lista enlazada:
- // push_front: agregar al inicio
- void push_front_char(char_list *lst, char element)
- {
- // crea el nodo
- char_link *e = malloc(sizeof *e);
- if(e){
- *e = (char_link){};
- e->data = element;
- insert_after((basic_list*)lst, 0, (link_base*)e);
- }
La función solo se encarga de la creación del nodo y la gestión de este se delega a
insert_after
.
- // pop_front: elimina un elemento del inicio de la lista
- char pop_front_char(char_list *lst)
- {
- char_link *e = remove_after((basic_list*)lst,
- (link_base*)lst->b4_head);
- char elem = e->data;
- free(e);
- return elem;
- }
Es convencional que pop devuela el elemento eliminado.
Otra operación de la lista que no hemos hablado es
clear
. Para la cual podemos utilizar remove_after
directamente o convenientemente a traves de pop_front_char
:
- // clear: deja la lista tal que is_empty((basic_list*)lst)==true.
- void clear_char_list(char_list *lst)
- {
- while(length((basic_list*)lst))
- pop_front_char(lst);
- }
Así de fácil. Esta bien, las estructuras de datos tienen sus detalles y C, muy a pesar de lo que muchos dicen, no es un lenguaje para principiantes. Pero tampoco tienen que ser mucho más difícil que en otros lenguajes y si resulta así es por el poco conocimiento de la estructura de datos o poco conocimiento del lenguaje que uses para implementarla (tal es mi caso con Rust en el ejemplo). Espero que se vea el patrón… tal vez más adelante agregue C++ y el repo con el código.
Cualquier duda/corrección, en los comentarios.
Your point of view caught my eye and was very interesting. Thanks. I have a question for you. https://www.binance.com/kz/register?ref=V3MG69RO
Your enticle helped me a lot, is there any more related content? Thanks! https://accounts.binance.com/en/register-person?ref=53551167