En programación informática , se produce un problema de semipredicado cuando una subrutina que debería devolver un valor útil puede fallar, pero la señalización del fallo utiliza un valor de retorno que, de otro modo, sería válido . [ 1 ] El problema es que quien llama a la subrutina no puede saber qué significa el resultado en este caso.
Ejemplo
La división produce un número real , pero falla cuando el divisor es cero . Si escribiéramos una función que realizara la división, podríamos optar por devolver 0 en este caso. Sin embargo, si el dividendo es 0, el resultado también será 0. Esto significa que no existe un número que podamos devolver para indicar de forma unívoca que se ha intentado dividir por cero, ya que todos los números reales se encuentran dentro del rango de división.
Implicaciones prácticas
Los primeros programadores manejaban casos potencialmente excepcionales, como la división, mediante una convención que requería que la rutina que realizaba la llamada verificara las entradas antes de llamar a la función de división. Esto presentaba dos problemas: primero, sobrecargaba enormemente todo el código que realizaba divisiones (una operación muy común); segundo, violaba los principios de "no te repitas" y de encapsulación , el primero de los cuales sugiere eliminar el código duplicado, y el segundo sugiere que el código asociado a datos se contenga en un solo lugar (en este ejemplo de división, la verificación de la entrada se realizó por separado). Para un cálculo más complejo que la división, podría ser difícil para quien realizaba la llamada reconocer entradas no válidas; en algunos casos, determinar la validez de la entrada podría ser tan costoso como realizar todo el cálculo. La función de destino también podría modificarse y, en ese caso, esperaría precondiciones diferentes a las de quien realizaba la llamada; dicha modificación requeriría cambios en cada lugar donde se llamara a la función.
Soluciones
El problema del semipredicado no es universal entre las funciones que pueden fallar.
Utilizar una convención personalizada para interpretar los valores de retorno.
Si el rango de una función no cubre todo el espacio correspondiente al tipo de dato del valor de retorno de la función, se puede utilizar un valor que se sabe que es imposible en un cálculo normal. Por ejemplo, consideremos la función index, que toma una cadena y una subcadena, y devuelve el índice entero de la subcadena en la cadena principal. Si la búsqueda falla, la función puede programarse para devolver −1 (o cualquier otro valor negativo), ya que esto nunca puede significar un resultado exitoso.
Sin embargo, esta solución tiene sus problemas, ya que sobrecarga el significado natural de una función con una convención arbitraria:
- El programador debe recordar valores de fallo específicos para muchas funciones, que por supuesto no pueden ser idénticos si las funciones tienen rangos diferentes.
- Una implementación diferente de la misma función puede optar por utilizar un valor de error distinto, lo que puede provocar errores cuando los programadores cambian de entorno.
- Si la función que falla desea comunicar información útil sobre el motivo de su fallo, un único valor de fallo es insuficiente.
- Un entero con signo reduce a la mitad el rango de índices posibles para poder almacenar el bit de signo .
- Aunque el valor elegido no es un resultado válido para esta operación, podría ser una entrada válida para operaciones posteriores. Por ejemplo, en Python
str.finddevuelve −1 si no se encuentra la subcadena, [ 2 ] pero −1 es un índice válido (los índices negativos generalmente comienzan desde el final [ 3 ] ).
Retorno multivaluado
Muchos lenguajes permiten, mediante diversos mecanismos, que una función devuelva múltiples valores. Si esta opción está disponible, la función puede rediseñarse para devolver un valor booleano que indique éxito o fracaso, junto con su valor de retorno principal. Si son posibles varios modos de error, la función puede devolver un código de retorno enumerado (código de error) junto con su valor de retorno principal.
Entre las diversas técnicas para devolver múltiples valores se incluyen:
- Devuelve una tupla de valores. Esto es convencional en lenguajes (como Python ) que tienen un tipo de dato de tupla incorporado y una sintaxis especial para manejarlas: en Python,
x, y = f()llama a la funciónfque devuelve un par de valores y asigna los elementos del par a dos variables. - Valores de retorno secundarios como en Common Lisp . Todas las expresiones tienen un valor principal, pero se pueden devolver valores secundarios a los llamadores interesados. Por ejemplo, la
GETHASHfunción devuelve el valor de la clave dada en un mapa asociativo , o un valor predeterminado en caso contrario. Sin embargo, también devuelve un valor booleano secundario que indica si se encontró el valor, lo que permite distinguir entre los casos en que "no se encontró ningún valor" y "el valor encontrado es igual al valor predeterminado". Esto es diferente de devolver una tupla, ya que los valores de retorno secundarios son opcionales : si un llamador no los necesita, puede ignorarlos por completo, mientras que los valores de retorno de tupla son simplemente azúcar sintáctico para devolver y desempaquetar una lista, y cada llamador debe conocer y consumir todos los elementos devueltos. - Los lenguajes con paso por referencia —o equivalentes, como el paso por dirección mediante punteros— permiten el retorno de múltiples valores al designar algunos parámetros como parámetros de salida . En este caso, la función podría simplemente devolver el valor de error, pasando a la función una variable destinada a almacenar el resultado real. Esto es análogo al uso de un estado de salida para almacenar un código de error y a los flujos para devolver contenido.
- En los lenguajes orientados a objetos que utilizan la técnica de paso compartido , se utiliza una variante de los parámetros de salida , donde se pasa un objeto mutable a una función y este objeto se modifica para devolver valores.
- Los lenguajes de programación lógica como Prolog no tienen valores de retorno. En su lugar, se utilizan variables lógicas no vinculadas como parámetros de salida, que se unifican con los valores construidos en una llamada a un predicado.
Variable global para el estado de retorno
De forma similar a un argumento "out", una variable global puede almacenar qué error se produjo (o simplemente si se produjo un error).
Por ejemplo, si se produce un error y se señala (generalmente como se indicó anteriormente, mediante un valor no válido como -1), la errnovariable Unix se establece para indicar qué valor se produjo. El uso de una variable global tiene sus inconvenientes habituales: la seguridad de los subprocesos se convierte en un problema (los sistemas operativos modernos utilizan una versión segura para subprocesos de errno), y si solo se utiliza una variable global de error, su tipo debe ser lo suficientemente amplio como para contener toda la información relevante sobre todos los posibles errores del sistema.
Excepciones
Las excepciones son un método ampliamente utilizado para resolver este problema. Una condición de error no se considera un valor de retorno de la función; el flujo de control normal se interrumpe y el manejo explícito del error se realiza automáticamente. Son un ejemplo de señalización fuera de banda .
Ampliación del tipo de valor de retorno
Tipos híbridos creados manualmente
En C , un enfoque común, cuando es posible, es usar un tipo de datos deliberadamente más amplio de lo estrictamente necesario para la función. Por ejemplo, la función estándar getchar()se define con un tipo de retorno inty devuelve un valor en el rango(el rango de unsigned char) en caso de éxito o el valor EOF( definido por la implementación , pero fuera del rango de unsigned char) al final de la entrada o un error de lectura.
Tipos de referencia anulables
En lenguajes con punteros o referencias, una solución es devolver un puntero a un valor, en lugar del valor mismo. Este puntero de retorno puede luego establecerse para nullindicar un error. Por lo general, es adecuado para funciones que de todos modos devuelven un puntero. Esto tiene una ventaja de rendimiento sobre el estilo de manejo de excepciones de la POO, [ 4 ] con el inconveniente de que los programadores negligentes pueden no verificar el valor de retorno, lo que resulta en un fallo cuando se usa el puntero no válido. Si un puntero es nulo o no es otro ejemplo del problema del predicado; nulo puede ser una bandera que indica un fallo o el valor de un puntero devuelto correctamente. Un patrón común en el entorno UNIX es establecer una variable separada para indicar la causa de un error. Un ejemplo de esto es la función de la biblioteca estándar de C.fopen()
Tipos híbridos implícitos
En lenguajes de tipado dinámico , como PHP y Lisp , el enfoque habitual es devolver `null` false, none`false` o `false` nullcuando falla la llamada a la función. Esto funciona devolviendo un tipo diferente al tipo de retorno normal (expandiendo así el tipo). Es el equivalente en tipado dinámico a devolver un puntero nulo.
Por ejemplo, una función numérica normalmente devuelve un número (entero o decimal), y si bien cero puede ser una respuesta válida, falso no lo es. De manera similar, una función que normalmente devuelve una cadena puede a veces devolver la cadena vacía como respuesta válida, pero devolver falso en caso de error. Este proceso de manipulación de tipos requiere cuidado al probar el valor de retorno: por ejemplo, en PHP, use ===(es decir, igual y del mismo tipo) en lugar de simplemente ==(es decir, igual, después de la conversión automática de tipos). Esto solo funciona cuando la función original no está destinada a devolver un valor booleano, y aún requiere que la información sobre el error se transmita por otros medios.
Tipos híbridos explícitos
En Haskell y otros lenguajes de programación funcional , es común usar un tipo de dato tan grande como sea necesario para expresar cualquier resultado posible. Por ejemplo, se puede escribir una función de división que devuelva el tipo Maybe Realy una getcharfunción que devuelva Either String Char. La primera es un tipo de opción , que tiene un único valor de error, Nothing. El segundo caso es una unión etiquetada : el resultado es una cadena con un mensaje de error descriptivo o un carácter leído correctamente. El sistema de inferencia de tipos de Haskell ayuda a garantizar que quienes llaman a la función manejen los posibles errores. Dado que las condiciones de error se hacen explícitas en el tipo de función, al observar su firma se le indica inmediatamente al programador cómo tratar los errores. Además, las uniones etiquetadas y los tipos de opción forman mónadas cuando se les dotan de las funciones apropiadas: esto se puede usar para mantener el código ordenado propagando automáticamente las condiciones de error no manejadas.
Ejemplo
Rust tiene tipos de datos algebraicos y viene con los tipos incorporados std::result::Result<T, E>[ 5 ] y [ 6 ] .std::option::Option<T>
fn find ( key : String ) -> Option < String > { if key == "hello" { Some ( key ) } else { None } }Véase también
Referencias
- ↑ Norvig, Peter (1992). «El solucionador general de problemas». Paradigmas de la programación de inteligencia artificial: estudios de caso en LISP común . Morgan Kaufmann . pág. 127. ISBN 1-55860-191-0.
- ↑ "Tipos integrados" . Documentación de Python 3.10.4 .
- ↑ "Si
iojes negativo, el índice es relativo al final de la secuencias:len(s) + iolen(s) + jse sustituye." Nota sobre operaciones de secuencia comunes (3) . - ↑ Por qué las excepciones deben ser excepcionales: un ejemplo de comparación de rendimiento .
- ↑ El equipo de Rust (25 de mayo de 2026). "Resultado de enumeración" . doc.rust-lang.org . El equipo de Rust.
- ↑ El equipo de Rust (25 de mayo de 2026). "Opción Enum" . doc.rust-lang.org . El equipo de Rust.
- Temas de lenguajes de programación
