¿La duplicación de código es un mal necesario en C?

16

Soy bastante nuevo en C, y me pregunto si la duplicación de código es un mal necesario cuando se trata de escribir estructuras de datos comunes y C en general.

hash mapPor ejemplo, podría intentar escribir una implementación genérica , pero siempre encuentro que el resultado final es desordenado. También podría escribir una implementación especializada solo para este caso de uso específico, mantener el código claro y fácil de leer y depurar. Esto último, por supuesto, conduciría a cierta duplicación de código.

¿Son las implementaciones genéricas una norma, o escribes implementaciones diferentes para cada caso de uso?

Gerstmann
fuente
11
El dilema moral que está describiendo no es exclusivo de C. Anecdóticamente, encuentro que la barra para crear una implementación genérica es bastante alta, a menos que tenga una audiencia para ello. La cantidad de esfuerzo requerida para crear una buena implementación genérica supera con creces la solución puntual, IME.
Robert Harvey
3
@RobertHarvey en mi experiencia, el código reutilizable requiere 3-4 veces más esfuerzos para escribir
mosquito
1
@kevincline: Bueno, Java tiene genéricos. De acuerdo, es el tipo de genéricos "no le prestes atención al hombre detrás de la cortina".
Robert Harvey
1
Y antes de los genéricos, podría haber pasado en Object y pasado en Float, Double, Integer y Long, respectivamente.
Daniel Kaplan
3
Deja vu es un código de verificación de olores y la preferencia por DRY está en la raíz de todas las mejores prácticas que vale la pena prestar atención a IMO. Pero me pegué un tiro en el pie en lenguajes orientados a OOP tratando demasiado de evitar la duplicación por sí misma. Si no se vinculan innecesariamente entre sí preocupaciones innecesarias o si se destruye la legibilidad en aras de que dos funciones similares se conviertan en una sola, iría con el instinto en casi cualquier idioma.
Erik Reppen

Respuestas:

27

C hace que sea difícil escribir código genérico. A diferencia de C ++, que le brinda plantillas y funciones virtuales, C solo tiene 3 mecanismos para escribir código genérico:

  1. void* punteros
  2. Macros de preprocesador
  3. Punteros de función

void* los punteros están lejos de ser ideales, ya que pierde toda la seguridad de tipo proporcionada por el compilador, lo que puede dar lugar a un comportamiento indefinido difícil de depurar como resultado de conversiones de tipo no válidas.

Las macros de preprocesador tienen inconvenientes bien conocidos: la expansión de preprocesador es básicamente un mecanismo de búsqueda / reemplazo que ocurre antes de la fase de compilación, lo que nuevamente puede provocar errores difíciles de depurar. El ejemplo arquetípico es algo como:, #define add(x) (x+x)donde xpuede incrementarse dos veces si llama add(i++). Usted puede escribir al estilo de plantilla de código genérico utilizando enteramente C-macros, pero el resultado es realmente horrible y difícil de mantener .

Los punteros de función proporcionan una buena manera de escribir código genérico, pero desafortunadamente no le brindan generalidad de tipo : simplemente brindan la posibilidad de polimorfismo en tiempo de ejecución (por lo que, por ejemplo, la biblioteca estándar qsorttodavía requiere una función que tome void*punteros)

También puede implementar jerarquías de clases en C usando estructuras, como se hace en la biblioteca GLib que proporciona una GObjectclase base genérica . Pero esto tiene problemas similares a los del uso de void*punteros, ya que aún debe confiar en la transmisión manual potencialmente insegura para la transmisión ascendente y descendente.

Entonces, sí, C hace que sea difícil escribir código que sea genérico Y seguro / fácil de mantener, lo que desafortunadamente puede resultar en la duplicación de código. Los proyectos grandes de C a menudo usan lenguajes de script para generar código repetitivo durante el proceso de compilación.

Charles Salvia
fuente
1
También se podría generar código C repetitivo usando lenguajes / herramientas de plantillas externas o un script de shell o usando ant o ...
Trabajo
¿Cuál es esta URL rusa ofuscada por Google que ha colocado en su respuesta? Al hacer clic en él, aparece un mensaje de "abrir" en mi navegador. ¿Es esto seguro?
Robert Harvey
2
@RobertHarvey Mis análisis antivirus y de seguridad piensan que está bien. Es un archivo de encabezado de lenguaje C, extensión .h. Parece legítimo.
maple_shaft
@maple_shaft: OK. He eliminado la Googlización alrededor del enlace.
Robert Harvey
Es solo un archivo de texto plano .h (encabezado C)
Charles Salvia
15

No puedo hablar por otros, pero en mi propia experiencia personal con C, la duplicación de código no ha sido un gran problema. Si esto se debe al tamaño de los proyectos o a un conjunto de muestras encantado, no puedo decirlo. Sin embargo, hay tres reglas generales que sigo que creo que son aplicables. Sin ningún orden en particular, son ...

  1. Escribe para lo que se necesita. La generalidad puede venir después SI es necesario.
  2. Si se necesita generalidad, los punteros vacíos, los punteros de función y el tamaño de la estructura pueden ser invaluables. La rutina qsort (), por ejemplo, hace uso de los tres.
  3. Dale importancia a aclarar tu código.
Chispeante
fuente
5

Vale la pena señalar que es posible que no necesite una solución totalmente genérica para reducir la duplicación de código. A veces, la refactorización ordinaria y un poco de generalización del método son suficientes.

Al escribir una solución generalizada para un público amplio, debe tener en cuenta:

  1. ¿De qué formas novedosas podría el consumidor usar su código y cómo puede acomodarlas?
  2. ¿Qué errores necesito atrapar? ¿Qué errores no debo atrapar?
  3. ¿Qué tan robusta debe ser la API? ¿Cuántas sobrecargas necesito proporcionar?
  4. ¿Qué medidas de seguridad debo poner en práctica para que el código no pueda usarse con fines malvados?

Eric Lippert escribió un artículo completo sobre una sola decisión de diseño en el código del marco .NET. En general, a menudo es más simple simplemente refactorizar.

Robert Harvey
fuente
3

Usualmente uso una implementación genérica, como la de glib , luego, si el casting termina siendo demasiado molesto, hago un pequeño contenedor específico para cada tipo. Sin embargo, se espera una gran cantidad de casting en C, al igual que el uso de a void*como un tipo genérico, por lo que lo que se consideraría "desordenado" en otro idioma es el típico C. Bastante asustadizo, se verá mucho más natural cuando Adquiera más experiencia en el idioma.

Karl Bielefeldt
fuente
2
"Lo que se consideraría 'desordenado' en otro idioma es el típico C. Bastante aterrador, se verá mucho más natural cuando tenga más experiencia en el idioma". ¡Jajaja! C siempre ha sido uno de mis lenguajes favoritos, ¡pero sigue siendo bastante divertido!
GlenPeterson
0

Su problema de reutilización no es exclusivo de C. Sin embargo, existen algunas implementaciones de HashMap excelentes y muy reutilizables en las que puede basar la suya. Uno que ha existido durante una década o dos está en Java: http://docs.oracle.com/javase/7/docs/api/java/util/HashMap.html

Todas las colecciones de Java, incluido HashMap, se están reescribiendo para Java 8 para deshacerse de la idea de un iterador, y en su lugar requieren que pase una función a la colección para que pueda iterar internamente. Esta es una gran victoria para escribir código concurrente: la colección puede administrar la concurrencia internamente para que el cliente no tenga que hacerlo. Dicho esto, la mayoría de los programadores no están acostumbrados a pasar punteros de función. Esto hace un buen trabajo explicando hacia dónde se dirige HashMap: http://cr.openjdk.java.net/~briangoetz/lambda/collections-overview.html

La sintaxis de Scala puede dificultar la traducción de algo como esto a C, pero aquí hay un ejemplo de una colección que ya funciona de la manera en que las colecciones de Java 8 serán inmutables por defecto, proporcionando varios métodos de transformación, haciendo uso de punteros de función y otros cosas interesantes como una aplicación parcial: http://www.scala-lang.org/api/current/index.html#scala.collection.Map

Si está haciendo muchas cosas con HashMaps y otras estructuras de datos más avanzadas, es posible que desee ver un lenguaje que las soporte.

Volviendo a la idea de la duplicación de código en general, puede codificar realmente rápido cortando y pegando, pero produce un código terrible. Yo diría que es aceptable en código desechable, como informes únicos. También está bien a veces cuando no hay un flujo descendente de código, como el código de la interfaz de usuario que solo construye una pantalla de la que nada más depende, pero en realidad es un área muy gris en el mejor de los casos.

No creo que sea una simplificación excesiva decir que cada vez que te encuentres duplicando código, deberías hacer una función. Solo los errores que corrige en una copia del código y no en la otra son razón suficiente para hacer una función.

GlenPeterson
fuente
1
- "No creo que sea una simplificación excesiva decir que cada vez que te encuentres duplicando código, deberías hacer una función" - En C eso es una simplificación excesiva. Intente hacer una Pila en C para enteros, dobles y cadenas sin duplicar las funciones push y pop para cada tipo de datos.
mike30
@mike ¿No es posible usar punteros vacíos?
sakisk
@mike: buen punto. Pero un contrapunto sería que podría implementar Stack para cada tipo de datos de tal manera que no necesite implementar Stack nuevamente. Entonces, el objetivo no es numberOfImplementations = 1, sino más como 5. Es posible que pueda hacer que la intimplementación funcione para chars ... Ha pasado tanto tiempo desde que usé C que no puedo recordar.
GlenPeterson
1
@faif. Sí, pero la necesidad de usar punteros vacíos hace que C. los punteros de lanzamiento sean más lentos y menos elegantes que las alternativas encontradas en C ++. Duplicar el código ocupa un lugar más alto en la lista de posibles opciones al codificar en C.
mike30
@ mike30 ¿Hay alguna prueba / referencia sobre: ​​"Lanzar punteros en C es más lento que las alternativas encontradas en C ++?"
sakisk
0

Me pregunto si la duplicación de código es un mal necesario cuando se trata de escribir estructuras de datos comunes y C en general.

En C, absolutamente para mí, como alguien que rebota entre C y C ++. Definitivamente duplico más cosas triviales a diario en C que en C ++, pero deliberadamente, y no necesariamente lo veo como "malo" porque hay al menos algunos beneficios prácticos. Creo que es un error considerar todas las cosas como estrictamente "bueno" o "malo", casi todo es cuestión de compensaciones. Comprender esas compensaciones claramente es la clave para no evitar decisiones lamentables en retrospectiva, y simplemente etiquetar las cosas como "buenas" o "malas" generalmente ignora todas esas sutilezas.

Si bien el problema no es exclusivo de C como señalaron otros, podría estar considerablemente más exacerbado en C debido a la falta de algo más elegante que las macros o los punteros nulos para los genéricos, la incomodidad de la POO no trivial y el hecho de que La biblioteca estándar de C no viene con ningún contenedor. En C ++, una persona que implemente su propia lista vinculada puede hacer que una multitud enojada de personas exija por qué no están usando la biblioteca estándar, a menos que sean estudiantes. En C, invitaría a una mafia enojada si no puede desplegar con confianza una elegante implementación de lista vinculada mientras duerme, ya que a menudo se espera que un programador de C al menos pueda hacer ese tipo de cosas a diario. Eso' No se debe a una extraña obsesión en las listas vinculadas de que Linus Torvalds utilizó la implementación de la búsqueda y eliminación de SLL utilizando la doble indirección como criterio para evaluar a un programador que entiende el lenguaje y tiene "buen gusto". Esto se debe a que los programadores de C podrían estar obligados a implementar esa lógica miles de veces en su carrera. En este caso para C, es como un chef que evalúa las habilidades de un nuevo cocinero al hacer que solo prepare algunos huevos para ver si al menos dominan las cosas básicas que deberán hacer todo el tiempo.

Por ejemplo, probablemente he implementado esta estructura de datos básica de "lista libre indexada" una docena de veces en C localmente para cada sitio que usa esta estrategia de asignación (casi todas mis estructuras vinculadas para evitar asignar un nodo a la vez y reducir a la mitad la memoria costos de los enlaces en 64 bits):

ingrese la descripción de la imagen aquí

Pero en C solo lleva una cantidad muy pequeña de código a reallocuna matriz ampliable y agrupa un poco de memoria usando un enfoque indexado a una lista libre al implementar una nueva estructura de datos que usa esta.

Ahora tengo lo mismo implementado en C ++ y solo lo tengo implementado una vez como plantilla de clase. Pero es una implementación mucho, mucho más compleja en el lado de C ++ con cientos de líneas de código y algunas dependencias externas que también abarcan cientos de líneas de código. Y la razón principal por la que es mucho más complicado es porque tengo que codificarlo contra la idea de que Tpodría ser cualquier tipo de datos posible. Podría arrojarse en cualquier momento dado (excepto al destruirlo, lo que tengo que hacer explícitamente como con los contenedores de biblioteca estándar), tuve que pensar en la alineación adecuada para asignar memoria paraT (aunque afortunadamente esto se hace más fácil en C ++ 11 en adelante), podría ser no trivialmente construible / destructible (requiere colocación de invocaciones de dtor nuevas y manuales), tengo que agregar métodos que no todo necesitará, pero algunas cosas necesitarán, y tengo que agregar iteradores, iteradores mutables y de solo lectura (const), y así sucesivamente.

Las matrices cultivables no son ciencia espacial

En C ++, las personas hacen que parezca que std::vectores el trabajo de un científico de cohetes, optimizado hasta la muerte, pero no funciona mejor que una matriz C dinámica codificada contra un tipo de datos específico que solo se usa reallocpara aumentar la capacidad de la matriz en los retrocesos con un Docena de líneas de código. La diferencia es que se necesita una implementación muy compleja para hacer que una secuencia de acceso aleatorio que se pueda desarrollar cumpla totalmente con el estándar, evite invocar a los factores no insertados, seguro para excepciones, proporcione iteradores de acceso aleatorio const y no const, use type rasgos para desambiguar los rellenos de los generadores de rango para ciertos tipos integrales deT, puede tratar los POD de manera diferente utilizando rasgos de tipo, etc., etc., etc. En ese momento, de hecho, necesita una implementación muy compleja solo para crear una matriz dinámica que se pueda crecer, pero solo porque está tratando de manejar todos los casos de uso posibles que se puedan imaginar. En el lado positivo, puede obtener una gran cantidad de millas de todo ese esfuerzo adicional si realmente necesita almacenar POD y UDT no triviales, usar algoritmos genéricos basados ​​en iteradores que funcionan en cualquier estructura de datos compatible, beneficiarse del manejo de excepciones y RAII, al menos a veces anular std::allocatorcon su propio asignador personalizado, etc. etc. Definitivamente vale la pena en la biblioteca estándar cuando considera cuánto beneficiostd::vector ha tenido en todo el mundo las personas que lo han usado, pero eso es para algo implementado en la biblioteca estándar diseñada para satisfacer las necesidades del mundo entero.

Implementaciones más simples Manejo de casos de uso muy específicos

Como resultado de solo manejar casos de uso muy específicos con mi "lista libre indexada", a pesar de implementar esta lista gratuita una docena de veces en el lado C y tener un código trivial duplicado como resultado, probablemente haya escrito menos código total en C para implementar eso una docena de veces de lo que tuve que implementarlo solo una vez en C ++, y tuve que pasar menos tiempo manteniendo esas docenas de implementaciones de C de lo que tuve que mantener esa implementación de C ++. Una de las razones principales por las que el lado C es tan simple es que generalmente estoy trabajando con POD en C cada vez que uso esta técnica y generalmente no necesito más funciones que insertyeraseen los sitios específicos en los que implemento esto localmente. Básicamente, puedo implementar el subconjunto más joven de la funcionalidad que ofrece la versión C ++, ya que soy libre de hacer muchas más suposiciones sobre lo que hago y no necesito del diseño cuando lo estoy implementando para un uso muy específico. caso.

Ahora, la versión C ++ es mucho más agradable y segura de usar, pero aún así fue una PITA importante para implementar y hacer que el iterador sea seguro y excepcionalmente bidireccional, por ejemplo, en formas en las que crear una implementación general reutilizable probablemente cuesta más tiempo del que realmente ahorra en este caso. Y gran parte de ese costo de implementarlo de manera generalizada se desperdicia no solo por adelantado, sino repetidamente en forma de cosas como los tiempos de construcción escalonados pagados una y otra vez cada día.

¡No es un ataque en C ++!

Pero esto no es un ataque a C ++ porque me encanta C ++, pero cuando se trata de estructuras de datos, he llegado a favorecer a C ++ principalmente por las estructuras de datos realmente no triviales en las que quiero pasar mucho tiempo adicional por adelantado para implementar en de una manera muy generalizada, haga una excepción segura contra todos los tipos posibles T, haga que cumpla con los estándares e iterable, etc., donde ese tipo de costo inicial realmente vale la pena en forma de una tonelada de kilometraje.

Sin embargo, eso también promueve una mentalidad de diseño muy diferente. En C ++ si quiero hacer un Octree para la detección de colisión, tengo la tendencia a querer generalizarlo en el enésimo grado. No solo quiero que almacene mallas triangulares indexadas. ¿Por qué debería limitarlo a un solo tipo de datos con el que pueda trabajar cuando tengo un mecanismo de generación de código súper potente al alcance de la mano que elimina todas las penalizaciones por abstracción en tiempo de ejecución? Quiero que almacene esferas de procedimiento, cubos, vóxeles, superficies NURB, nubes de puntos, etc., etc., e intente que sea bueno para todo, porque es tentador querer diseñarlo de esa manera cuando tienes plantillas a tu alcance. Es posible que ni siquiera quiera limitarlo a la detección de colisiones: ¿qué tal el trazado de rayos, la selección, etc.? C ++ hace que inicialmente parezca "algo fácil" generalizar una estructura de datos al enésimo grado. Y así es como solía diseñar tales índices espaciales en C ++. Traté de diseñarlos para manejar las necesidades de hambre del mundo entero, y lo que obtuve a cambio fue típicamente un "gato de todos los oficios" con un código extremadamente complejo para equilibrarlo con todos los posibles casos de uso imaginables.

Sin embargo, curiosamente, he obtenido más reutilización de los índices espaciales que he implementado en C a lo largo de los años, y sin culpa de C ++, pero solo mía en lo que el lenguaje me tienta a hacer. Cuando codifico algo así como un octree en C, tengo la tendencia de hacerlo funcionar con puntos y estar contento con eso, porque el lenguaje hace que sea demasiado difícil incluso intentar generalizarlo en el enésimo grado. Pero debido a esas tendencias, a lo largo de los años he tendido a diseñar cosas que en realidad son más eficientes y confiables y realmente adecuadas para ciertas tareas, ya que no se molestan en ser generales en el enésimo grado. Se convierten en ases en una categoría especializada en lugar de un jack de todos los oficios. De nuevo, eso no es culpa de C ++, sino simplemente de las tendencias humanas que tengo cuando lo uso en lugar de C.

Pero de todos modos, amo ambos idiomas, pero hay diferentes tendencias. En CI tenemos una tendencia a no generalizar lo suficiente. En C ++ tengo una tendencia a generalizar demasiado. Usar ambos me ha ayudado a equilibrarme.

¿Son las implementaciones genéricas una norma, o escribes implementaciones diferentes para cada caso de uso?

Para cosas triviales como listas indexadas de 32 bits enlazadas individualmente usando nodos de una matriz o una matriz que se reasigna a sí misma (equivalente analógico de std::vectoren C ++) o, por ejemplo, un octree que solo almacena puntos y apunta a no hacer nada más, no lo hago ' t molesta en generalizar hasta el punto de almacenar cualquier tipo de datos. Los implemento para almacenar un tipo de datos específico (aunque puede ser abstracto y usar punteros de función en algunos casos, pero al menos más específicos que la escritura de pato con polimorfismo estático).

Y estoy perfectamente feliz con un poco de redundancia en esos casos, siempre que la unidad lo pruebe a fondo. Si no realizo una prueba unitaria, la redundancia comienza a sentirse mucho más incómoda, porque es posible que tenga un código redundante que podría estar duplicando errores, por ejemplo, incluso si el tipo de código que está escribiendo es poco probable que necesite algún cambio de diseño, aún podría necesitar cambios porque está roto. Tiendo a escribir pruebas unitarias más exhaustivas para el código C que escribo como razón.

Para cosas no triviales, generalmente es cuando alcanzo C ++, pero si tuviera que implementarlo en C, consideraría usar solo void*punteros, tal vez acepte un tamaño de letra para saber cuánta memoria asignar para cada elemento, y posiblemente copy/destroypunteros de función copiar en profundidad y destruir los datos si no son trivialmente construibles / destructibles. La mayoría de las veces no me molesto y no uso tanto C para crear las estructuras de datos y algoritmos más complejos.

Si usa una estructura de datos con la frecuencia suficiente con un tipo de datos en particular, también podría ajustar una versión segura de tipo sobre una que solo funcione con bits y bytes y punteros de función y void*, por ejemplo, volver a imponer la seguridad de tipo a través del contenedor C.

Podría intentar escribir una implementación genérica para un mapa hash, por ejemplo, pero siempre encuentro que el resultado final es desordenado. También podría escribir una implementación especializada solo para este caso de uso específico, mantener el código claro y fácil de leer y depurar. Esto último, por supuesto, conduciría a cierta duplicación de código.

Las tablas de hash son un poco dudosas, ya que podría ser trivial de implementar o realmente complejo, dependiendo de cuán complejas sean sus necesidades con respecto a los hashes, las repeticiones, si necesita que la tabla crezca automáticamente de manera implícita o puede anticipar el tamaño de la tabla en avance, ya sea que use el direccionamiento abierto o el encadenamiento separado, etc. Pero una cosa a tener en cuenta es que si adapta una tabla hash perfectamente a las necesidades de un sitio específico, a menudo no será tan compleja en la implementación y a menudo se ganará No sea tan redundante cuando está diseñado precisamente para esas necesidades. Al menos esa es la excusa que me doy si implemento algo localmente. Si no, puede usar el método descrito anteriormente con void*punteros de función para copiar / destruir cosas y generalizarlo.

A menudo, no se necesita mucho esfuerzo ni mucho código para vencer una estructura de datos muy generalizada si su alternativa es extremadamente estrictamente aplicable a su caso de uso exacto. Como ejemplo, es absolutamente trivial superar el rendimiento del uso mallocde todos y cada uno de los nodos (en lugar de agrupar un montón de memoria para muchos nodos) de una vez por todas con el código que nunca tiene que volver a visitar para un caso de uso muy, muy exacto incluso cuando mallocsalen nuevas implementaciones . Puede llevar toda una vida superarlo y codificar no menos complejo que tener que dedicar una gran parte de su vida a mantenerlo y mantenerlo actualizado si desea igualar su generalidad.

Como otro ejemplo, a menudo me resulta extremadamente fácil implementar soluciones que son 10 veces más rápidas o más que las soluciones VFX ofrecidas por Pixar o Dreamworks. Puedo hacerlo mientras duermo. Pero eso no se debe a que mis implementaciones sean superiores, ni mucho menos. Son francamente inferiores para la mayoría de las personas. Solo son superiores para mis casos de uso muy, muy específicos. Mis versiones son mucho, mucho menos aplicables en general que las de Pixar o Dreamwork. Es una comparación ridículamente injusta ya que sus soluciones son absolutamente brillantes en comparación con mis soluciones simples, pero ese es el punto. La comparación no necesita ser justa. Si todo lo que necesita son algunas cosas muy específicas, no necesita hacer que una estructura de datos maneje una lista interminable de cosas que no necesita.

Bits y bytes homogéneos

Una cosa para explotar en C ya que tiene una falta inherente de seguridad de tipo es la idea de almacenar cosas de manera homogénea en función de las características de bits y bytes. Hay más de un desenfoque allí como resultado entre el asignador de memoria y la estructura de datos.

Pero almacenar un montón de cosas de tamaño variable, o incluso cosas que simplemente podrían ser de tamaño variable, como un polimórfico Dogy Cat, es difícil de hacer de manera eficiente. No puede suponerse que podrían ser de tamaño variable y almacenarlos contiguamente en un contenedor de acceso aleatorio simple porque la zancada para pasar de un elemento al siguiente podría ser diferente. Como resultado, para almacenar una lista que contiene perros y gatos, es posible que tenga que usar 3 instancias de estructura / asignador de datos separadas (una para perros, otra para gatos y otra para una lista polimórfica de punteros base o punteros inteligentes, o peor) , asigne a cada perro y gato contra un asignador de propósito general y dispersarlos por toda la memoria), lo que se vuelve costoso e incurre en su cuota de errores de caché multiplicados.

Entonces, una estrategia para utilizar en C, aunque se trata de una riqueza y seguridad de tipo reducidas, es generalizar a nivel de bits y bytes. Puede suponer eso Dogsy Catsrequerir el mismo número de bits y bytes, tener los mismos campos, el mismo puntero a una tabla de punteros de función. Pero a cambio, puede codificar menos estructuras de datos, pero igual de importante, almacenar todas estas cosas de manera eficiente y contigua. En ese caso, está tratando a los perros y los gatos como uniones analógicas (o podría usar una unión).

Y eso tiene un costo enorme para escribir la seguridad. Si hay algo que extraño más que cualquier otra cosa en C, es la seguridad de tipo. Se está acercando al nivel de ensamblaje donde las estructuras solo indican cuánta memoria se asigna y cómo se alinea cada campo de datos. Pero esa es en realidad mi razón número uno para usar C. Si realmente está tratando de controlar los diseños de memoria y dónde se asigna todo y dónde se almacena todo entre sí, a menudo es útil pensar en cosas a nivel de bits y bytes y cuántos bits y bytes necesita para resolver un problema en particular. Allí, la estupidez del sistema de tipos de C puede ser realmente beneficiosa en lugar de una desventaja. Por lo general, eso terminará dando como resultado muchos menos tipos de datos con los que lidiar,

Duplicación ilusoria / aparente

Ahora he estado usando "duplicación" en un sentido suelto para cosas que ni siquiera pueden ser redundantes. He visto a personas distinguir términos como duplicación "incidental / aparente" de "duplicación real". La forma en que lo veo es que no hay una distinción tan clara en muchos casos. Encuentro la distinción más como "unicidad potencial" versus "duplicación potencial" y puede ir en cualquier dirección. A menudo depende de cómo desea que evolucionen sus diseños e implementaciones y cuán perfectamente adaptados serán para un caso de uso específico. Pero a menudo he descubierto que lo que podría parecer una duplicación de código más tarde ya no es redundante después de varias iteraciones de mejoras.

Tome una implementación sencilla de matriz ampliable usando reallocel equivalente analógico de std::vector<int>. Inicialmente podría ser redundante, por ejemplo, con el uso std::vector<int>en C ++. Pero puede encontrar, a través de la medición, que podría ser beneficioso preasignar 64 bytes por adelantado para permitir la inserción de dieciséis enteros de 32 bits sin requerir una asignación de almacenamiento dinámico. Ahora ya no es redundante, al menos no con std::vector<int>. Y luego podría decir: "Pero podría generalizar esto a uno nuevo SmallVector<int, 16>, y podría hacerlo. Pero digamos que encuentra que es útil porque se trata de matrices muy pequeñas y de corta duración para cuadruplicar la capacidad de matriz en asignaciones de almacenamiento dinámico en lugar de aumentando en 1.5 (aproximadamente la cantidad que muchosvectorimplementaciones) mientras se trabaja asumiendo que la capacidad de la matriz siempre es una potencia de dos. Ahora su contenedor es realmente diferente, y probablemente no haya un contenedor como este. Y tal vez podría tratar de generalizar tales comportamientos agregando más y más parámetros de plantilla para personalizar el sanador de preasignación, personalizar el comportamiento de reasignación, etc. etc., pero en ese momento puede encontrar algo realmente difícil de usar en comparación con una docena de líneas de C simple. código.

E incluso podría llegar a un punto en el que necesita una estructura de datos que asigne memoria alineada y acolchada de 256 bits, almacenando exclusivamente POD para instrucciones AVX 256, preasigna 128 bytes para evitar asignaciones de montón para tamaños de entrada pequeños de casos comunes, duplica la capacidad cuando completo, y permite sobrescribir de forma segura los elementos finales que exceden el tamaño del conjunto pero que no exceden la capacidad del conjunto. En ese punto, si todavía está tratando de generalizar una solución para evitar duplicar una pequeña cantidad de código C, que los dioses de la programación tengan piedad de su alma.

Por lo tanto, también hay momentos como este en los que lo que inicialmente comienza a verse como redundante comienza a crecer, a medida que adapta una solución para mejorar cada vez más un determinado caso de uso, en algo completamente único y no redundante en absoluto. Pero eso es solo para cosas donde puede permitirse adaptarlas perfectamente a un caso de uso específico. A veces solo necesitamos algo "decente" que esté generalizado para nuestro propósito, y allí me beneficio más de las estructuras de datos muy generalizadas. Pero para cosas excepcionales perfectamente hechas para un caso de uso particular, la idea de "propósito general" y "hecho perfectamente para mi propósito" comienza a ser demasiado incompatible.

POD y primitivas

Ahora en C, a menudo encuentro excusas para almacenar POD y especialmente primitivas en estructuras de datos siempre que sea posible. Puede parecer un antipatrón, pero en realidad lo he encontrado inadvertidamente útil para mejorar la capacidad de mantenimiento del código sobre los tipos de cosas que solía hacer con más frecuencia en C ++.

Un ejemplo simple son las cadenas cortas internas (como suele ser el caso con las cadenas utilizadas para las teclas de búsqueda, tienden a ser muy cortas). ¿Por qué molestarse en tratar con todas estas cadenas de longitud variable cuyos tamaños varían en tiempo de ejecución, lo que implica una construcción y destrucción no trivial (ya que es posible que necesitemos asignar y liberar montón)? ¿Qué tal si simplemente almacena estas cosas en una estructura de datos central, como una tabla trie o hash segura para subprocesos diseñada solo para la internación de cadenas, y luego se refieren a esas cadenas con un viejo int32_to simple :

struct IternedString 
{
    int32_t index;
};

... en nuestras tablas hash, árboles rojo-negros, listas de salto, etc., si no necesitamos clasificación lexicográfica? Ahora, todas nuestras otras estructuras de datos que codificamos para trabajar con enteros de 32 bits ahora pueden almacenar estas claves de cadena interna que son efectivamente de solo 32 bits ints. Y he encontrado al menos en mis casos de uso (podría ser mi dominio ya que trabajo en áreas como trazado de rayos, procesamiento de malla, procesamiento de imágenes, sistemas de partículas, enlace a lenguajes de secuencias de comandos, implementaciones de kit de GUI multiproceso de bajo nivel, etc.) cosas de bajo nivel, pero no tan de bajo nivel como un sistema operativo), que el código casualmente se vuelve más eficiente y simple simplemente almacenando índices para cosas como esta. Eso hace que a menudo esté trabajando, digamos el 75% del tiempo, con solo int32_tyfloat32 en mis estructuras de datos no triviales, o simplemente almacenando cosas que son del mismo tamaño (casi siempre de 32 bits).

Y, naturalmente, si eso es aplicable a su caso, puede evitar tener una serie de implementaciones de estructura de datos para diferentes tipos de datos, ya que en primer lugar trabajará con muy pocos.

Pruebas y confiabilidad

Una última cosa que ofrecería, y podría no ser para todos, es favorecer la escritura de pruebas para esas estructuras de datos. Hazlos realmente buenos en algo. Asegúrate de que sean ultra confiables.

Alguna duplicación de código menor se vuelve mucho más perdonable en esos casos, ya que la duplicación de código es solo una carga de mantenimiento si tiene que hacer cambios en cascada en el código duplicado. Elimina una de las principales razones para que cambie ese código redundante asegurándote de que sea ultra confiable y realmente adecuado para lo que está tratando de hacer.

Mi sentido de la estética ha cambiado con los años. Ya no me irrito porque veo que una biblioteca implementa un producto de puntos o alguna lógica SLL trivial que ya está implementada en otra. Solo me irrito cuando las cosas están mal probadas y no son confiables, y descubrí que es una mentalidad mucho más productiva. Realmente he tratado con bases de código que duplican errores a través de código duplicado, y he visto los peores casos de codificación de copiar y pegar haciendo que lo que debería haber sido un cambio trivial en un lugar central se convierta en un cambio en cascada propenso a errores para muchos. Sin embargo, muchas de esas veces, fue el resultado de malas pruebas, de que el código no se volvió confiable y bueno en lo que estaba haciendo en primer lugar. Antes, cuando estaba trabajando en bases de código heredadas con errores, mi mente asociaba todas las formas de duplicación de código con una probabilidad muy alta de duplicar errores y requerir cambios en cascada. Sin embargo, una biblioteca en miniatura que hace una cosa extremadamente bien y confiablemente encontrará muy pocas razones para cambiar en el futuro, incluso si tiene algún código de aspecto redundante aquí y allá. Mis prioridades estaban apagadas en aquel entonces cuando la duplicación me irritó más que la mala calidad y la falta de pruebas. Estas últimas cosas deberían ser la máxima prioridad.

¿Duplicación de código para el minimalismo?

Este es un pensamiento divertido que apareció en mi cabeza, pero consideremos un caso en el que podríamos encontrar una biblioteca C y C ++ que hace aproximadamente lo mismo: ambos tienen aproximadamente la misma funcionalidad, la misma cantidad de manejo de errores, uno no es significativamente más eficiente que el otro, etc. Y lo más importante, ambos están implementados de manera competente, bien probados y confiables. Desafortunadamente, tengo que hablar hipotéticamente aquí ya que nunca he encontrado nada parecido a una comparación perfecta de lado a lado. Pero las cosas más cercanas que he encontrado en esta comparación lado a lado a menudo tenían la biblioteca de C siendo mucho, mucho más pequeña que el equivalente de C ++ (a veces 1/10 de su tamaño de código).

Y creo que la razón de esto es porque, nuevamente, para resolver un problema de una manera general que maneja la más amplia gama de casos de uso en lugar de un caso de uso exacto, podría requerir de cientos a miles de líneas de código, mientras que el último solo podría requerir una docena. A pesar de la redundancia, y a pesar del hecho de que la biblioteca estándar C es abismal cuando se trata de proporcionar estructuras de datos estándar, a menudo termina produciendo menos código en manos humanas para resolver los mismos problemas, y creo que eso se debe principalmente a las diferencias en las tendencias humanas entre estos dos idiomas. Uno promueve la solución de cosas contra un caso de uso muy específico, el otro tiende a promover soluciones más abstractas y genéricas contra la más amplia gama de casos de uso, pero el resultado final de estos no

Estaba mirando el raytracer de alguien en Github el otro día y se implementó en C ++ y requería mucho código para un raytracer de juguete. Y no pasé tanto tiempo mirando el código, pero había un montón de estructuras de uso general allí que manejaban mucho más de lo que necesitaría un rastreador de rayos. Y reconozco ese estilo de codificación porque solía usar C ++ de la misma manera de una manera súper ascendente, centrándome en hacer primero una biblioteca completa de estructuras de datos de propósito muy general que van mucho más allá de lo inmediato problema en cuestión y luego abordar el problema real segundo Pero si bien esas estructuras generales podrían eliminar cierta redundancia aquí y allá y disfrutar de una gran reutilización en nuevos contextos, a cambio, inflan enormemente el proyecto al intercambiar un poco de redundancia con un montón de código / funcionalidad innecesarios, y el último no es necesariamente más fácil de mantener que el primero. Por el contrario, a menudo me resulta más difícil de mantener, ya que es difícil mantener un diseño de algo general que tenga que equilibrar las decisiones de diseño de equilibrio contra la gama más amplia de necesidades posibles.


fuente