Expresiones y declaraciones de asignación 7.1 Introducción 7.2 Expresiones aritméticas 7.3 Operadores sobrecargados 7.4 Conversiones de tipo 7.5 Expresiones relacionales y booleanas 7.6 Evaluación del cortocircuito 7.7 Declaraciones de asignación 7.8 Asignación de modo mixto Como indica el título, el tema de este capítulo son las expresiones y las sentencias de asignación. En primer lugar, se tratan las reglas semánticas que determinan el orden de evaluación de los operadores en las expresiones. A continuación, se explica de un problema potencial con el orden de evaluación de los operandos cuando las funciones pueden tener efectos secundarios. A continuación, se analizan los operadores sobrecargados, tanto los predefinidos como los definidos por el usuario, junto con sus efectos en las expresiones de los programas. A continuación, se describen y evalúan las expresiones de modo mixto. Esto lleva a la definición y evaluación de las conversiones de tipo de ampliación y reducción, tanto implícitas como explícitas. A continuación, se discuten las expresiones relacionales y booleanas, incluyendo el proceso de evaluación de cortocircuito. Por último, se cubre la sentencia de asignación, desde su forma más simple hasta todas sus variaciones, incluyendo las asignaciones como expresiones y las asignaciones de modo mixto. Las expresiones de concordancia de patrones de cadenas de caracteres se trataron como parte del material sobre cadenas de caracteres en el capítulo 6, por lo que no se mencionan en este capítulo. 7.1 Introducción Las expresiones son el medio fundamental para especificar los cálculos en un lenguaje de programación. Es crucial para un programador entender tanto la sintaxis como la semántica de las expresiones del lenguaje que utiliza. En el capítulo 3 se presentó un mecanismo formal (BNF) para describir la sintaxis de las expresiones. En este capítulo se analiza la semántica de las expresiones. Para entender la evaluación de expresiones, es necesario conocer los órdenes de evaluación de operadores y operandos. El orden de evaluación de los operadores en las expresiones viene dictado por las reglas de asociatividad y precedencia del lenguaje. Aunque el valor de una expresión a veces depende de ello, el orden de evaluación de los operandos en las expresiones a menudo no es indicado por los diseñadores del lenguaje. Esto permite a los implementadores elegir el orden, lo que lleva a la posibilidad de que los programas produzcan resultados diferentes en diferentes implementaciones. Otros problemas en la semántica de las expresiones son los desajustes de tipo, las coerciones y la evaluación en cortocircuito. La esencia de los lenguajes de programación imperativos es el papel dominante de las sentencias de asignación. El propósito de estas sentencias es causar el efecto secundario de cambiar los valores de las variables, o el estado, del programa. Así que una parte integral de todos los lenguajes imperativos es el concepto de variables cuyos valores cambian durante la ejecución del programa. Los lenguajes funcionales utilizan variables de otro tipo, como los parámetros de las funciones. Estos lenguajes también tienen declaraciones que vinculan valores a nombres. Estas declaraciones son similares a las declaraciones de asignación, pero no tienen efectos secundarios. 7.2 Expresiones aritméticas La evaluación automática de expresiones aritméticas similares a las de la matemática, la ciencia y la ingeniería fue uno de los principales objetivos de los primeros lenguajes de programación de alto nivel. La mayoría de las características de la aritmética Las expresiones en los lenguajes de programación se heredaron de las convenciones que habían evolucionado en las matemáticas. En los lenguajes de programación, las expresiones aritméticas constan de operadores, operandos, paréntesis y llamadas a funciones. Un operador puede ser unario, es decir, con un solo operando, binario, es decir, con dos operandos, o ternario, es decir, con tres operandos. En la mayoría de los lenguajes de programación, los operadores binarios son infijos, lo que significa que aparecen entre sus operandos. Una excepción es Perl, que tiene algunos operadores que son prefijos, lo que significa que preceden a sus operandos. El propósito de una expresión aritmética es especificar una computación aritmética. Una implementación de tal computación debe causar dos acciones: obtener los operandos, normalmente de la memoria, y ejecutar operaciones aritméticas en esos operandos. En las siguientes secciones, investigamos los detalles de diseño comunes de las expresiones aritméticas. A continuación se exponen los principales problemas de diseño de las expresiones aritméticas, todos ellos analizados en esta sección: ● ¿Cuáles son las reglas de precedencia de los operadores? ● ● ● ● ● ¿Cuáles son las reglas de asociatividad de los operadores? ¿Cuál es el orden de evaluación de los operandos? ¿Existen restricciones en los efectos secundarios de la evaluación de operandos? ¿Permite el lenguaje la sobrecarga de operadores definida por el usuario? ¿Qué mezcla de tipos se permite en las expresiones? 7.2.1 Orden de evaluación del operador Las reglas de precedencia y asociatividad de los operadores de un lenguaje dictan el orden de evaluación de sus operadores. 7.2.1.1 Precedencia El valor de una expresión depende, al menos en parte, del orden de evaluación de los operadores en la expresión. Considere la siguiente expresión: a+b*c Supongamos que las variables a, b y c tienen los valores 3, 4 y 5, respectivamente. Si se evalúa de izquierda a derecha (primero la suma y luego la multiplicación), el resultado es 35. Si se evalúa de derecha a izquierda, el resultado es 23. En lugar de evaluar simplemente los operadores de una expresión de izquierda a derecha o de derecha a izquierda, los matemáticos desarrollaron hace tiempo el concepto de colocar los operadores en una jerarquía de prioridades de evaluación y basar el orden de evaluación de las expresiones en parte en esta jerarquía. Por ejemplo, en matemáticas, la multiplicación se considera más prioritaria que la suma, quizás debido a su mayor nivel de complejidad. Si esa convención se aplicará en la expresión del ejemplo anterior, como sería el caso en la mayoría de los lenguajes de programación, la multiplicación se haría primero. Las reglas de precedencia de operadores para la evaluación de expresiones definen parcialmente el orden en que se evalúan los operadores de diferentes niveles de precedencia. Las reglas de precedencia de los operadores para las expresiones se basan en la jerarquía de las prioridades de los operadores, según el diseñador del lenguaje. Las reglas de precedencia de operadores de los lenguajes imperativos comunes son casi todas iguales, porque se basan en las de las matemáticas. En estos lenguajes, la exponenciación tiene la mayor precedencia (cuando es proporcionada por el lenguaje), seguida de la multiplicación y la división en el mismo nivel, seguida de la suma y la resta binarias en el mismo nivel. Muchos lenguajes también incluyen versiones unarias de la suma y la resta. La suma unaria se denomina operador de identidad porque normalmente no tiene ninguna operación asociada y, por tanto, no tiene ningún efecto sobre su operando. Ellis y Stroustrup (1990, p. 56), hablando de C++, lo llaman un accidente histórico y lo califican correctamente de inútil. El menos unario, por supuesto, cambia el signo de su operando. En Java y C#, el menos unario también provoca la conversión implícita de los operandos short y byte a tipo int. En todos los lenguajes imperativos comunes, el operador unario menos puede aparecer en una expresión, ya sea al principio o en cualquier parte de la misma, siempre que esté entre paréntesis para evitar que esté junto a otro operador. Por ejemplo, a + (- b) * c es legal, pero a+-b*c normalmente no lo es. A continuación, considera las siguientes expresiones: ● ● ● a/b a*b a ** b En los dos primeros casos, la precedencia relativa del operador menos unario y del operador binario es irrelevante: el orden de evaluación de los dos operadores no tiene ningún efecto sobre el valor de la expresión. En el último caso, sin embargo, sí importa. De los lenguajes de programación comunes, sólo Fortran, Ruby, Visual Basic y Ada tienen el operador de exponenciación. En los cuatro, la exponenciación tiene mayor precedencia que el menos unario, por lo que ● A ** B equivale a ● (A ** B) La precedencia es sólo una parte de las reglas para el orden de evaluación de los operadores; las reglas de asociatividad también la afectan. 7.2.1.2 Asociatividad Considera la siguiente expresión: a-b+c-d Si los operadores de suma y resta tienen el mismo nivel de precedencia, como ocurre en los lenguajes de programación, las reglas de precedencia no dicen nada sobre el orden de evaluación de los operadores en esta expresión. Cuando una expresión contiene dos ocurrencias adyacentes de operadores con el mismo nivel de precedencia, la cuestión de qué operador se evalúa primero se responde con las reglas de asociatividad del lenguaje. Un operador puede tener asociatividad izquierda o derecha, lo que significa que cuando hay dos operadores adyacentes con la misma precedencia, se evalúa primero el operador izquierdo o el derecho, respectivamente. La asociatividad en los lenguajes comunes es de izquierda a derecha, excepto que el operador de exponenciación (cuando se proporciona) a veces asocia de derecha a izquierda. En la expresión de Java a-b+c El operador de la izquierda se evalúa primero. La exponenciación en Fortran y Ruby es asociativa a la derecha, por lo que en la expresión A ** B ** C El operador derecho se evalúa primero. En Visual Basic, el operador de exponenciación, ^, es asociativo a la izquierda. Las reglas de asociatividad para algunos lenguajes comunes se dan aquí: Regla de asociatividad lingüística Lenguajes basados en C Izquierda: *, /, %, binario +, binario A la derecha: ++, --, unario -, unario + Como se indica en el apartado 7.2.1.1, en APL todos los operadores tienen el mismo nivel de precedencia. Por lo tanto, el orden de evaluación de los operadores en las expresiones APL está determinado completamente por la regla de asociatividad, que es de derecha a izquierda para todos los operadores. Por ejemplo, en la expresión A×B+C el operador de adición se evalúa primero, seguido del operador de multiplicación ( * es el operador de multiplicación de APL). Si A fuera 3, B fuera 4 y C fuera 5, entonces el valor de esta expresión APL sería 27. Muchos compiladores para los lenguajes comunes hacen uso del hecho de que algunos operadores aritméticos son matemáticamente asociativos, lo que significa que las reglas de asociatividad no tienen impacto en el valor de una expresión que contenga sólo esos operadores. Por ejemplo, la suma es matemáticamente asociativa, por lo que en matemáticas el valor de la expresión A+B+C no depende del orden de evaluación del operador. Si las operaciones de punto flotante para las operaciones matemáticas asociativas fueran también asociativas, el compilador podría utilizar este hecho para realizar algunas optimizaciones simples. En concreto, si el compilador puede reordenar la evaluación de los operadores, podría producir un código ligeramente más rápido para la evaluación de expresiones. Los compiladores suelen realizar este tipo de optimizaciones. Por desgracia, en un ordenador, tanto las representaciones en coma flotante como las operaciones aritméticas en coma flotante son sólo aproximaciones de sus homólogos matemáticos (debido a las limitaciones de tamaño). El hecho de que un operador matemático sea asociativo no implica necesariamente que la correspondiente operación en coma flotante sea asociativa. De hecho, sólo si todos los operandos y resultados intermedios pueden representarse exactamente en notación de punto flotante, el proceso será precisamente asociativo. Por ejemplo, hay situaciones patológicas en el que la suma de enteros en un ordenador no es asociativa. Por ejemplo, supongamos que un programa debe evaluar la expresión A+B+C+D y que A y C son números positivos muy grandes, y B y D son números negativos con valores absolutos muy grandes. En esta situación, añadir B a A no provoca una excepción de desbordamiento, pero añadir C a A sí. Del mismo modo, la adición de C a B no provoca un desbordamiento, pero la adición de D a B sí. Debido a las limitaciones de la aritmética computacional, la suma es catastróficamente no asociativa en este caso. Por lo tanto, si el compilador reordena estas operaciones de adición, afecta al valor de la expresión. Este problema, por supuesto, puede ser evitado por el programador, asumiendo que los valores aproximados de las variables son conocidos. El programador puede especificar la expresión en dos partes (en dos sentencias de asignación), asegurándose de evitar el desbordamiento. Sin embargo, esta situación puede surgir de formas mucho más sutiles, en las que es menos probable que el programador se dé cuenta de la dependencia de orden. 7.2.1.3 Paréntesis Los programadores pueden alterar las reglas de precedencia y asociatividad colocando paréntesis en las expresiones. Una parte con paréntesis de una expresión tiene precedencia sobre sus partes adyacentes sin paréntesis. Por ejemplo, aunque la multiplicación tiene precedencia sobre la suma, en la expresión (A + B) * C la suma se evaluará primero. Matemáticamente, esto es perfectamente natural. En esta expresión, el primer operando del operador de multiplicación no está disponible hasta que se evalúe la adición en la subexpresión entre paréntesis. Además, la expresión del apartado 7.2.1.2 podría especificarse como (A + B) + (C + D) para evitar el desbordamiento. Los lenguajes que permiten los paréntesis en las expresiones aritméticas podrían prescindir de todas las reglas de precedencia y asociar simplemente todos los operadores de izquierda a derecha o de derecha a izquierda. El programador especificaría el orden de evaluación deseado con paréntesis. Este enfoque sería sencillo porque ni el autor ni los lectores de los programas tendrían que recordar ninguna regla de precedencia o asociatividad. La desventaja de este esquema es que hace más tediosa la escritura de expresiones, y también compromete seriamente la legibilidad del código. Sin embargo, esta fue la elección de Ken Iverson, el diseñador de APL. 7.2.1.4 Expresiones condicionales Las sentencias if-then-else pueden utilizarse para realizar una asignación de expresión condicional. Por ejemplo, considere si (cuenta == 0) media = 0; si no media = suma / recuento; En los lenguajes basados en C, este código se puede especificar más convenientemente en una sentencia de asignación utilizando una expresión condicional, que tiene la siguiente forma: expresión_1 ? expresión_2 : expresión_3 donde expresión_1 se interpreta como una expresión booleana. Si la expresión_1 se evalúa como verdadera, el valor de la expresión completa es el valor de la expresión_2; en caso contrario, es el valor de la expresión_3. Por ejemplo, el efecto del ejemplo if-then-else puede conseguirse con la siguiente sentencia de asignación, utilizando una expresión condicional: media = (cuenta == 0) ? 0 : suma / recuento; En efecto, el signo de interrogación denota el comienzo de la cláusula then, y los dos puntos marcan el comienzo de la cláusula else. Ambas cláusulas son obligatorias. Tenga en cuenta que ? Se utiliza en las expresiones condicionales como operador ternario. Las expresiones condicionales pueden utilizarse en cualquier parte de un programa (en un lenguaje basado en C) donde pueda utilizarse cualquier otra expresión. Además de los lenguajes basados en C, las expresiones condicionales se ofrecen en Perl, JavaScript y Ruby. 7.2.2 Orden de evaluación del operando Una característica de diseño de las expresiones que se discute menos es el orden de evaluación de los operandos. Las variables de las expresiones se evalúan obteniendo sus valores de la memoria. Las constantes a veces se evalúan de la misma manera. En otros casos, una constante puede ser parte de la instrucción del lenguaje de máquina y no requerir una búsqueda en memoria. Si un operando es una expresión entre paréntesis, todos los operadores que contiene deben ser evaluados antes de que su valor pueda ser utilizado como operando. Si ninguno de los operandos de un operador tiene efectos secundarios, el orden de evaluación de los operandos es irrelevante. Por lo tanto, el único caso interesante se produce cuando la evaluación de un operando tiene efectos secundarios. 7.2.2.1 Efectos secundarios Un efecto secundario de una función, naturalmente llamado efecto secundario funcional, ocurre cuando la función cambia uno de sus parámetros o una variable global. (Una variable global se declara fuera de la función pero es accesible en la función). Considera la siguiente expresión: a + fun(a) Si fun no tiene el efecto secundario de cambiar a, entonces el orden de evaluación de los dos operandos, a y fun(a), no tiene efecto sobre el valor de la expresión. Sin embargo, si fun cambia a, hay un efecto. Consideremos la siguiente situación: fun devuelve 10 y cambia el valor de su parámetro a 20. Supongamos que tenemos lo siguiente: a = 10; b = a + fun(a); Entonces, si el valor de a se obtiene primero (en el proceso de evaluación de la expresión), su valor es 10 y el valor de la expresión es 20. Pero si el segundo operando se evalúa primero, entonces el valor del primer operando es 20 y el valor de la expresión es 30. El siguiente programa en C ilustra el mismo problema cuando una función cambia una variable global que aparece en una expresión: int a = 5; int fun1() { a = 17; volver 3; }/* fin de fun1 */ void main() { a = a + fun1(); }/* fin de main */ El valor calculado para a en main depende del orden de evaluación de los operandos en la expresión a + fun1(). El valor de a será 8 (si a se evalúa primero) o 20 (si la llamada a la función se evalúa primero). Obsérvese que las funciones en matemáticas no tienen efectos secundarios, porque no existe la noción de variables en matemáticas. Lo mismo ocurre con los lenguajes de programación funcionales. Tanto en las matemáticas como en los lenguajes de programación funcional, las funciones son mucho más fáciles de razonar y entender que las de los lenguajes imperativos, porque su contexto es irrelevante para su significado. Hay dos posibles soluciones al problema del orden de evaluación de los operandos y los efectos secundarios. En primer lugar, el diseñador del lenguaje podría impedir que la evaluación de la función afecte al valor de las expresiones, simplemente desestimando los efectos secundarios de la función. En segundo lugar, la definición del lenguaje podría establecer que los operandos de las expresiones deben evaluarse en un orden determinado y exigir que los implementadores garanticen ese orden. No permitir los efectos secundarios funcionales en los lenguajes imperativos es difícil, y elimina cierta flexibilidad para el programador. Consideremos el caso de C y C++, que sólo tienen funciones, lo que significa que todos los subprogramas devuelven un valor. Para eliminar los efectos secundarios de los parámetros bidireccionales y seguir proporcionando subprogramas que devuelvan más de un valor, habría que colocar los valores en una estructura y devolver la estructura. El acceso a los globales en las funciones también tendría que ser desautorizado. Sin embargo, cuando la eficiencia es importante, utilizar el acceso a variables globales para evitar el paso de parámetros es un método importante para aumentar la velocidad de ejecución. En los compiladores, por ejemplo, el acceso global a datos como la tabla de símbolos es habitual. El problema de tener un orden de evaluación estricto es que algunas técnicas de optimización de código utilizadas por los compiladores implican la reordenación de las evaluaciones de los operandos. Un orden garantizado no permite esos métodos de optimización cuando hay llamadas a funciones. Por lo tanto, no existe una solución perfecta, como demuestran los diseños de lenguajes reales. La definición del lenguaje Java garantiza que los operandos aparezcan evaluados en orden de izquierda a derecha, eliminando el problema discutido en esta sección. 7.2.2.2 Transparencia referencial y efectos secundarios El concepto de transparencia referencial está relacionado con los efectos secundarios funcionales y se ve afectado por ellos. Un programa tiene la propiedad de transparencia referencial si dos expresiones cualesquiera del programa que tengan el mismo valor pueden ser sustituidas la una por la otra en cualquier parte del programa, sin afectar a la acción del programa. El valor de una función con transparencia referencial depende totalmente de sus parámetros. La conexión entre la transparencia referencial y los efectos secundarios funcionales se ilustra con el siguiente ejemplo: resultado1 = (fun(a) + b) / (fun(a) - c); temp = fun(a); resultado2 = (temp + b) / (temp - c); Si la función fun no tiene efectos secundarios, el resultado1 y el resultado2 serán iguales, porque las expresiones asignadas a ellos son equivalentes. Sin embargo, supongamos que fun tiene el efecto secundario de añadir 1 a b o a c. Entonces el resultado1 no sería igual al resultado2. Entonces, ese efecto secundario viola la transparencia referencial del programa en el que aparece el código. Los programas transparentes desde el punto de vista de la referencia tienen varias ventajas. La más importante es que la semántica de estos programas es mucho más fácil de entender que la de los programas que no son transparentes desde el punto de vista de la referencia. La transparencia referencial hace que una función sea equivalente a una función matemática, en términos de facilidad de comprensión. Al no tener variables, los programas escritos en lenguajes funcionales puros son transparentes desde el punto de vista referencial. Las funciones en un lenguaje funcional puro no pueden tener estado, que se almacenaría en variables locales. Si una función de este tipo utiliza un valor de fuera de la función, ese valor debe ser una constante, ya que no hay variables. Por lo tanto, el valor de la función depende de los valores de sus parámetros. La transparencia referencial se analizará con más detalle en el capítulo 15. 7.3 Operadores sobrecargados Los operadores aritméticos se utilizan a menudo para más de un propósito. Por ejemplo, + suele utilizarse para especificar la suma de enteros y la suma de puntos flotantes. Algunos lenguajes -Java, por ejemplo- también lo utilizan para la catenización de cadenas. Este uso múltiple de un operador se denomina sobrecarga de operadores y, en general, se considera aceptable, siempre que no se resienta la legibilidad ni la fiabilidad. Como ejemplo de los posibles peligros de la sobrecarga, considere el uso del ampersand (& ) en C++. Como operador binario, especifica una operación lógica AND a nivel de bits. Como operador unario, sin embargo, su significado es totalmente diferente. Como operador unario con una variable como operando, el valor de la expresión es la dirección de esa variable. En este caso, el ampersand se llama operador de dirección. Por ejemplo, la ejecución de x = &y; hace que la dirección de y se coloque en x. Hay dos problemas con este uso múltiple del ampersand. En primer lugar, el uso del mismo símbolo para dos operaciones completamente no relacionadas es perjudicial para la legibilidad. En segundo lugar, el simple error de tecleado de omitir el primer operando para una operación AND a nivel de bits puede no ser detectado por el compilador, porque se interpreta como un operador de dirección. Este error puede ser difícil de diagnosticar. Prácticamente todos los lenguajes de programación tienen un problema menos grave pero similar, que a menudo se debe a la sobrecarga del operador menos. El problema es sólo que el compilador no puede saber si el operador debe ser binario o unario. 5 Así que, una vez más, el compilador no puede detectar como error el hecho de no incluir el primer operando cuando el operador se supone que es binario. Sin embargo, los significados de las dos operaciones, unaria y binaria, están al menos estrechamente relacionados, por lo que la legibilidad no se ve afectada negativamente. Algunos lenguajes que admiten tipos de datos abstractos (véase el capítulo 11), por ejemplo, C++, C# y F#, permiten al programador sobrecargar aún más los símbolos de los operadores. Por ejemplo, supongamos que un usuario quiere definir el operador * entre un entero escalar y un array de enteros para significar que cada elemento del array debe ser multiplicado por el escalar. Este operador podría definirse escribiendo un subprograma de función llamado * que realice esta nueva operación. El compilador elegirá el significado correcto cuando se especifique un operador sobrecargado, basándose en los tipos de los operandos, como ocurre con los operadores sobrecargados definidos por el lenguaje. Por ejemplo, si esta nueva definición de * se define en un programa de C#, el compilador de C# utilizará la nueva definición de * siempre que el operador * aparezca con un entero simple como operando izquierdo y una matriz de enteros como operando derecho. Cuando se utiliza con sensatez, la sobrecarga de operadores definida por el usuario puede ayudar a la legibilidad. Por ejemplo, si + y * están sobrecargados para un tipo de datos abstracto de matriz y A, B, C y D son variables de ese tipo, entonces A*B+C*D puede utilizarse en lugar de MatrixAdd(MatrixMult(A, B), MatrixMult(C, D)) Por otro lado, la sobrecarga definida por el usuario puede ser perjudicial para la legibilidad. Por un lado, nada impide que un usuario defina + para significar multiplicación. Además, al ver un operador * en un programa, el lector debe encontrar tanto los tipos de los operandos como la definición del operador para determinar su significado. Cualquiera de estas definiciones o todas ellas podrían estar en otros archivos. Otra consideración es el proceso de construcción de un sistema de software a partir de módulos creados por diferentes grupos. Si los distintos grupos sobrecargan los mismos operadores de forma diferente, es obvio que habrá que eliminar estas diferencias antes de montar el sistema. 7.4 Conversiones de tipo Las conversiones de tipos son de estrechamiento o de ampliación. Una conversión de estrechamiento convierte un valor a un tipo que no puede almacenar ni siquiera aproximaciones de todos los valores del tipo original. Por ejemplo, convertir un double a un float en Java es una conversión de estrechamiento, porque el rango de double es mucho mayor que el de float. Una conversión de ampliación convierte un valor a un tipo que puede incluir al menos aproximaciones de todos los valores del tipo original. Por ejemplo, convertir un int en un float en Java es una conversión de ampliación. Las conversiones de ampliación son casi siempre seguras, lo que significa que se mantiene la magnitud aproximada del valor convertido. Las conversiones de estrechamiento no siempre son seguras: a veces la magnitud del valor convertido cambia en el proceso. Por ejemplo, si el valor de punto flotante 1,3E25 se convierte en un entero en un programa Java, el resultado no tendrá ninguna relación con el valor original. Aunque las conversiones de ensanchamiento suelen ser seguras, pueden dar lugar a una reducción de la precisión. En muchas implementaciones de lenguajes, aunque las conversiones de enteros a punto flotante son conversiones de ampliación, se puede perder algo de precisión. Por ejemplo, en muchos casos, los enteros se almacenan en 32 bits, lo que permite al menos 9 dígitos decimales de precisión. Pero los valores en coma flotante también se almacenan en 32 bits, con sólo unos siete dígitos decimales de precisión (debido al espacio utilizado para el exponente). Por tanto, la ampliación de enteros a punto flotante puede provocar la pérdida de dos dígitos de precisión. Las coerciones de los tipos no primitivos son, por supuesto, más complejas. En el capítulo 5, se discutieron las complicaciones de la compatibilidad de asignación de los tipos de array y de registro. También está la cuestión de qué tipos de parámetros y tipos de retorno de un método le permiten anular un método de una superclase, sólo cuando los tipos son los mismos, o también algunas otras situaciones. Esa cuestión, así como el concepto de subclases como subtipos, se tratan en el capítulo 12. Las conversiones de tipo pueden ser explícitas o implícitas. En las dos siguientes subsecciones se analizan estos tipos de conversiones de tipo. 7.4.1 Coerción en las expresiones Una de las decisiones de diseño relativas a las expresiones aritméticas es si un operador puede tener operandos de diferentes tipos. Los lenguajes que permiten este tipo de expresiones, que se denominan expresiones de modo mixto, deben definir convenciones para las conversiones implícitas de tipos de operandos porque los ordenadores no tienen operaciones binarias que tomen operandos de diferentes tipos. La coerción se definió como una conversión de tipos implícita que es iniciada por el compilador o el sistema de tiempo de ejecución. Las conversiones de tipo solicitadas explícitamente por el programador se denominan conversiones explícitas, o castings, y no coerciones. Aunque algunos símbolos de operador pueden estar sobrecargados, suponemos que un sistema informático, ya sea en hardware o en algún nivel de simulación de software, tiene una operación para cada tipo de operando y operador definido en el lenguaje. 6 En el caso de los operadores sobrecargados en un lenguaje que utiliza la vinculación estática de tipos, el compilador elige el tipo correcto de operación basándose en los tipos de los operandos. Cuando los dos operandos de un operador no son del mismo tipo y eso es legal en el lenguaje, el compilador debe elegir uno de ellos para ser coercionado y generar el código para esa coerción. En la siguiente discusión, se examinan las opciones de diseño de coerción de varios lenguajes comunes. Los diseñadores de lenguajes no están de acuerdo con la cuestión de las coerciones en las expresiones aritméticas. Los que están en contra de una amplia gama de coerciones están preocupados por los problemas de fiabilidad que pueden resultar de dichas coerciones, porque reducen los beneficios de la comprobación de tipos. Los que prefieren incluir un amplio rango de coerciones están más preocupados por la pérdida de flexibilidad que resulta de las restricciones. La cuestión es si los programadores deben preocuparse por esta categoría de errores o si el compilador debe detectarlos. Como simple ilustración del problema, considere el siguiente código Java: int a; float b, c, d; ... d = b * a; Supongamos que el segundo operando del operador de multiplicación debía ser c, pero debido a un error de tecleado se tecleó como a. Como las expresiones de modo mixto son legales en Java, el compilador no detectaría esto como un error. Simplemente insertaría código para coaccionar el valor del operando int, a, a float. Si las expresiones de modo mixto no fueran legales en Java, el compilador habría detectado este error de tecleado como un error de tipo. Dado que la detección de errores se reduce cuando se permiten expresiones de modo mixto, F# y ML no las permiten. Por ejemplo, no permiten mezclar operandos enteros y de punto flotante en las expresiones. En la mayoría de los demás lenguajes comunes, no hay restricciones para las expresiones aritméticas de modo mixto. Los lenguajes basados en C tienen tipos enteros más pequeños que el tipo int. En Java, estos son byte y short. Los operandos de todos estos tipos se convierten en int cuando se les aplica prácticamente a cualquier operador. Por lo tanto, aunque los datos pueden ser almacenados en variables de estos tipos, no pueden ser manipulados antes de la conversión a un tipo mayor. Por ejemplo, considere el siguiente código Java: byte a, b, c; ... a = b + c; Los valores de b y c se coaccionan a int y se realiza una suma de int. A continuación, la suma se convierte a byte y se coloca en a. Dado el gran tamaño de las memorias de los ordenadores contemporáneos, hay pocos incentivos para utilizar byte y short, a menos que haya que almacenar un gran número de ellos. 7.4.2 Conversión explícita de tipos La mayoría de los lenguajes proporcionan alguna capacidad para realizar conversiones explícitas, tanto de ampliación como de reducción. En algunos casos, se producen mensajes de advertencia cuando una conversión explícita de estrechamiento provoca un cambio significativo en el valor del objeto que se está convirtiendo. En los lenguajes basados en C, las conversiones de tipo explícitas se llaman castings. Para especificar una conversión, el tipo deseado se coloca entre paréntesis justo antes de la expresión a convertir, como en (int) ángulo Una de las razones de los paréntesis alrededor del nombre del tipo en estas conversiones es que el primero de estos lenguajes, C, tiene varios nombres de tipo de dos palabras, como long int. En ML y F#, los castings tienen la sintaxis de las llamadas a funciones. Por ejemplo, en F# podríamos tener lo siguiente: float(suma) 7.4.3 Errores en las expresiones Durante la evaluación de una expresión pueden producirse varios errores. Si el lenguaje requiere una comprobación de tipo, ya sea estática o dinámica, entonces no pueden producirse errores de tipo de operando. Los errores que pueden ocurrir debido a las coerciones de los operandos en las expresiones ya han sido discutidos. Los otros tipos de errores se deben a las limitaciones de la aritmética del ordenador y a las limitaciones inherentes a la aritmética. El error más común ocurre cuando el resultado de una operación no puede ser representado en la celda de memoria donde debe ser almacenado. Esto se denomina desbordamiento o subdesbordamiento, dependiendo de si el resultado es demasiado grande o demasiado pequeño. Una limitación de la aritmética es que la división por cero no está permitida. Por supuesto, el hecho de que no esté permitida matemáticamente no impide que un programa intente hacerla. El desbordamiento de punto flotante, el desbordamiento por debajo de la línea y la división por cero son ejemplos de errores en tiempo de ejecución, que a veces se llaman excepciones. Las facilidades del lenguaje que permiten a los programas detectar y tratar las excepciones se discuten en el Capítulo 14. 7.5 Expresiones relacionales y booleanas Además de las expresiones aritméticas, los lenguajes de programación admiten expresiones relacionales y booleanas. 7.5.1 Expresiones relacionales Un operador relacional es un operador que compara los valores de sus dos operandos. Una expresión relacional tiene dos operandos y un operador relacional. El valor de una expresión relacional es booleano, excepto cuando booleano no es un tipo incluido en el lenguaje. Los operadores relacionales suelen estar sobrecargados para una variedad de tipos. La operación que determina la verdad o la falsedad de un La expresión relacional depende de los tipos de operandos. Puede ser simple, como en el caso de los operandos enteros, o compleja, como en el caso de los operandos de cadenas de caracteres. Normalmente, los tipos de operandos que pueden utilizarse para los operadores relacionales son los tipos numéricos, las cadenas y los tipos de enumeración. La sintaxis de los operadores relacionales para la igualdad y la desigualdad difiere entre algunos lenguajes de programación. Por ejemplo, para la desigualdad, los lenguajes basados en C utilizan !=, Lua utiliza ~=, Fortran 95+ utiliza .NE. o <> , y ML y F# utilizan <>. JavaScript y PHP tienen dos operadores relacionales adicionales, === y !==. Son similares a sus parientes, == y !=, pero evitan que sus operandos sean coaccionados. Por ejemplo, la expresión "7" == 7 es cierto en JavaScript, porque cuando una cadena y un número son los operandos de un operador relacional, la cadena se convierte en un número. Sin embargo, "7" === 7 es falso, porque no se realiza ninguna coerción sobre los operandos de este operador. Ruby utiliza == para el operador relacional de igualdad que utiliza coerciones, y eql? para la igualdad sin coerciones. Ruby usa === sólo en la cláusula when de su sentencia case, como se discute en el Capítulo 8. Los operadores relacionales tienen siempre una precedencia menor que los operadores aritméticos, por lo que en expresiones como a+1>2*b las expresiones aritméticas se evalúan primero. 7.5.2 Expresiones booleanas Las expresiones booleanas están formadas por variables booleanas, constantes booleanas, expresiones relacionales y operadores booleanos. Los operadores suelen incluir los de las operaciones AND, OR y NOT, y a veces los de OR exclusivo y equivalencia. Los operadores booleanos suelen tomar sólo operandos booleanos (variables booleanas, literales booleanos o expresiones relacionales) y producen valores booleanos. En las matemáticas de las álgebras booleanas, los operadores OR y AND deben tener la misma precedencia. Sin embargo, los lenguajes basados en C asignan una mayor precedencia a AND que a OR. Tal vez esto se deba a la correlación sin fundamento de la multiplicación con AND y de la suma con OR, que naturalmente asignaría mayor precedencia a AND. Dado que las expresiones aritméticas pueden ser los operandos de las expresiones relacionales, y las expresiones relacionales pueden ser los operandos de las expresiones booleanas, las tres categorías de operadores deben colocarse en diferentes niveles de precedencia, uno respecto del otro. La precedencia de los operadores aritméticos, relacionales y booleanos en los lenguajes basados en C es la siguiente: Highest post fix ++, -unario +, unario -, prefijo ++, --, ! *, /, % binario +, binario < , > , <=, >= =, != && || Las versiones de C anteriores a C99 son extrañas entre los lenguajes imperativos populares en el sentido de que no tienen un tipo booleano y, por tanto, no tienen valores booleanos. En su lugar, se utilizan valores numéricos para representar valores booleanos. En lugar de las operaciones booleanas, se utilizan variables escalares (numéricas o de caracteres) y constantes, donde el cero se considera falso y todos los valores distintos de cero se consideran verdaderos. El resultado de la evaluación de una expresión de este tipo es un número entero, con el valor 0 si es falso y 1 si es verdadero. Las expresiones aritméticas también pueden utilizarse para las expresiones booleanas en C99 y C++. Un resultado extraño del diseño de expresiones relacionales de C es que la siguiente expresión es legal: a>b>c El operador relacional más a la izquierda se evalúa primero porque los operadores relacionales de C son asociativos a la izquierda, produciendo 0 o 1. Luego este resultado se compara con la variable c. Luego, este resultado se compara con la variable c. Nunca hay una comparación entre b y c en esta expresión. Algunos lenguajes, incluyendo Perl y Ruby, proporcionan dos conjuntos de operadores lógicos binarios, && y para AND y || y or para OR. Una diferencia entre && y and (y || y or) es que las versiones deletreadas tienen menor precedencia. Además, and y or tienen la misma precedencia, pero && tiene mayor precedencia que ||. Si se incluyen los operadores no aritméticos de los lenguajes basados en C, hay más de 40 operadores y al menos 14 niveles diferentes de precedencia. Esto es una prueba clara de la riqueza de las colecciones de operadores y de la complejidad de las expresiones posibles en estos lenguajes. La legibilidad dicta que un lenguaje debe incluir un tipo booleano, como se dijo en el Capítulo 6, en lugar de utilizar simplemente tipos numéricos en las expresiones booleanas. Se pierde algo de detección de errores en el uso de tipos numéricos para los operandos booleanos, porque cualquier expresión numérica, ya sea intencionada o no, es un operando legal para un operador booleano. En los otros lenguajes imperativos, cualquier expresión no booleana utilizada como operando de un operador booleano se detecta como un error. 7.6 Evaluación del cortocircuito Una evaluación en cortocircuito de una expresión es aquella en la que se determina el resultado sin evaluar todos los operandos y/o operadores. Por ejemplo, el valor de la expresión aritmética (13 * a) * (b / 13 - 1) es independiente del valor de (b / 13 - 1) si a es 0, porque 0 * x = 0 para cualquier x. Así, cuando a es 0, no es necesario evaluar (b / 13 - 1) ni realizar la segunda multiplicación. Sin embargo, en las expresiones aritméticas, este atajo no se detecta fácilmente durante la ejecución, por lo que nunca se toma. El valor de la expresión booleana (a >= 0) && (b < 10) es independiente de la segunda expresión relacional si a < 0, porque la expresión (FALSE && (b < 10)) es FALSE para todos los valores de b. Por lo tanto, cuando a es menor que cero, no hay necesidad de evaluar b, la constante 10, la segunda expresión relacional o la operación &&. A diferencia del caso de las expresiones aritméticas, este atajo puede descubrirse fácilmente durante la ejecución. Para ilustrar un problema potencial con la evaluación sin cortocircuito de las expresiones booleanas, suponga que Java no utiliza la evaluación con cortocircuito. Se podría escribir un bucle de búsqueda de tablas utilizando la sentencia while. Una versión simple de código Java para tal búsqueda, asumiendo que list, que tiene elementos listlen, es el array a buscar y key es el valor buscado, es índice = 0; while ((index < listlen) && (list[index] != key)) index = index + 1; Si la evaluación no es un cortocircuito, ambas expresiones relacionales en la expresión booleana de la sentencia while se evalúan, independientemente del valor de la expresión primero. Por lo tanto, si la clave no está en la lista, el programa terminará con una excepción de subíndice fuera de rango. La misma iteración que tiene índice == listlen hará referencia a list[listlen], lo que provoca el error de indexación porque list está declarada para tener listlen-1 como valor de subíndice de límite superior. Si un lenguaje proporciona una evaluación en circuito corto de las expresiones booleanas y se utiliza, esto no es un problema. En el ejemplo anterior, un esquema de evaluación en circuito corto evaluaría el primer operando del operador AND, pero omitiría el segundo operando si el primero es falso. Un lenguaje que proporciona evaluaciones de cortocircuito de expresiones booleanas y también tiene efectos secundarios en las expresiones permite que se produzcan errores sutiles. Supongamos que la evaluación en cortocircuito se utiliza en una expresión y parte de la expresión que contiene un efecto secundario no se evalúa; entonces el efecto secundario se producirá sólo en las evaluaciones completas de toda la expresión. Si la corrección del programa depende del efecto secundario, la evaluación en cortocircuito puede dar lugar a un error grave. Por ejemplo, considere la expresión de Java (a > b) || ((b++) / 3) En esta expresión, b se cambia (en la segunda expresión aritmética) sólo cuando a <= b. Si el programador asumió que b se cambiaría cada vez que esta expresión se evalúa durante la ejecución (y la corrección del programa depende de ello), el programa fallará. En los lenguajes basados en C, los operadores AND y OR habituales, && y ||, respectivamente, son de circuito corto. Sin embargo, estos lenguajes también tienen operadores AND y OR a nivel de bits, & y |, respectivamente, que pueden utilizarse con operandos de valor booleano y no son de cortocircuito. Por supuesto, los operadores a nivel de bits sólo son equivalentes a los operadores booleanos habituales si todos los operandos están restringidos a ser 0 (para falso) o 1 (para verdadero). Todos los operadores lógicos de Ruby, Perl, ML, F# y Python se evalúan en corto circuito . 7.7 Declaraciones de asignación Como hemos dicho anteriormente, la sentencia de asignación es una de las construcciones centrales en los lenguajes imperativos. Proporciona el mecanismo por el cual el usuario puede cambiar dinámicamente los enlaces de los valores a las variables. En la siguiente sección, se discute la forma más simple de asignación. Las secciones siguientes describen una variedad de alternativas. 7.7.1 Asignaciones sencillas Casi todos los lenguajes de programación que se utilizan actualmente utilizan el signo de igualdad para el operador de asignación. Todos ellos deben utilizar algo diferente al signo igual para el operador relacional de igualdad para evitar confusiones con su operador de asignación. ALGOL 60 fue pionero en el uso de := como operador de asignación, que evita la confusión de la asignación con la igualdad. Ada también utiliza este operador de asignación. Las opciones de diseño de cómo se utilizan las asignaciones en un lenguaje han variado mucho. En algunos lenguajes, como Fortran y Ada, una asignación sólo puede aparecer como una sentencia independiente, y el destino está restringido a una sola variable. Sin embargo, hay muchas alternativas. 7.7.2 Objetivos condicionales Perl permite objetivos condicionales en las sentencias de asignación. Por ejemplo, considere ($flag ? $cuenta1 : $cuenta2) = 0; lo que equivale a if ($flag) { $count1 = 0; } si no { $count2 = 0; } 7.7.3 Operadores de asignación compuestos Un operador de asignación compuesta es un método abreviado para especificar una forma de asignación comúnmente necesaria. La forma de asignación que se puede abreviar con esta técnica tiene la variable de destino que también aparece como el primer operando en la expresión del lado derecho, como en a=a+b Los operadores de asignación compuestos fueron introducidos por ALGOL 68, posteriormente fueron adoptados en una forma ligeramente diferente por C, y forman parte de los demás lenguajes basados en C, así como de Perl, JavaScript, Python y Ruby. La sintaxis de estos operadores de asignación es la catenación del operador binario deseado al operador =. Por ejemplo, suma += valor; equivale a suma = suma + valor; Los lenguajes que soportan operadores de asignación compuestos tienen versiones para la mayoría de sus operadores binarios. 7.7.4 Operadores de asignación unarios Los lenguajes basados en C, Perl y JavaScript incluyen dos operadores aritméticos unarios especiales que en realidad son asignaciones abreviadas. Combinan las operaciones de incremento y decremento con la asignación. Los operadores ++ para el incremento y -- para el decremento pueden utilizarse en expresiones o para formar sentencias de asignación de un solo operador. Pueden aparecer como operadores prefijos, lo que significa que preceden a los operandos, o como operadores postfijos, lo que significa que siguen a los operandos. En la sentencia de asignación suma = ++ cuenta; el valor de count se incrementa en 1 y luego se asigna a sum. Esta operación también se podría plantear como cuenta = cuenta + 1; suma = cuenta; Si el mismo operador se utiliza como operador postfijo, como en suma = cuenta ++; la asignación del valor de count a sum ocurre primero; luego count se incrementa. El efecto es el mismo que el de las dos sentencias suma = cuenta; cuenta = cuenta + 1; Un ejemplo del uso del operador de incremento unario para formar una sentencia de asignación completa es cuenta ++; que simplemente incrementa la cuenta. No parece una asignación, pero ciertamente lo es. Es equivalente a la sentencia cuenta = cuenta + 1; Cuando dos operadores unarios se aplican al mismo operando, la asociación es de derecha a izquierda. Por ejemplo, en - cuenta ++ cuenta se incrementa primero y luego se niega. Por lo tanto, es equivalente a - (cuenta ++) en lugar de (- recuento) ++ 7.7.5 Asignación como expresión En los lenguajes basados en C, Perl y JavaScript, la sentencia de asignación produce un resultado, que es el mismo que el valor asignado al objetivo. Por tanto, puede utilizarse como expresión y como operando en otras expresiones. Este diseño trata el operador de asignación como cualquier otro operador binario, excepto que tiene el efecto secundario de cambiar su operando izquierdo. Por ejemplo, en C, es común escribir sentencias como while ((ch = getchar()) != EOF) { ... } En esta sentencia, el siguiente carácter del archivo de entrada estándar, normalmente el teclado, se obtiene con getchar y se asigna a la variable ch. El resultado, o valor asignado, se compara entonces con la constante EOF. Si ch no es igual a EOF, se ejecuta la sentencia compuesta {...}. Tenga en cuenta que la asignación debe estar entre paréntesis -en los lenguajes que soportan la asignación como una expresión, la precedencia del operador de asignación es menor que la de los operadores relacionales. Sin los paréntesis, el nuevo carácter se compararía primero con EOF. La desventaja de permitir que las sentencias de asignación sean operandos en las expresiones es que proporciona otro tipo de efecto secundario de la expresión. Este tipo de efecto secundario puede llevar a expresiones que son difíciles de leer y entender. Una expresión con cualquier tipo de efecto secundario tiene esta desventaja. Una expresión de este tipo no puede leerse como una expresión, que en matemáticas es una denotación de un valor, sino sólo como una lista de instrucciones con un orden de ejecución impar. Por ejemplo, la expresión a = b + (c = d / b) - 1 denota las instrucciones Asigna d / b a c Asigna b + c a temp Asigna temp - 1 a Obsérvese que el tratamiento del operador de asignación como cualquier otro operador binario permite el efecto de las asignaciones con múltiples objetivos, como suma = cuenta = 0; en el que primero se asigna el cero a count y luego se asigna el valor de count a sum. Esta forma de asignación de múltiples objetivos también es legal en Python. Hay una pérdida de detección de errores en el diseño en C de la operación de asignación que frecuentemente conduce a errores en el programa. En particular, si escribimos si (x = y) ... en lugar de si (x == y) ... que es un error fácil de cometer, no es detectable como error por el com- pilador. En lugar de comprobar una expresión relacional, se comprueba el valor que se asigna a x (en este caso, es el valor de y el que llega a esta sentencia). Esto es en realidad el resultado de dos decisiones de diseño: permitir que la asignación se comporte como un operador binario ordinario y utilizar dos operadores muy similares, y para tener significados completamente diferentes. Este es otro ejemplo de las deficiencias de seguridad de los programas C y C++. Tenga en cuenta que Java y C# sólo permiten expresiones booleanas en sus sentencias if, descartando este problema. 7.7.6 Asignaciones múltiples Varios lenguajes de programación recientes, como Perl, Ruby y Lua, proporcionan estados de asignación de múltiples objetivos y fuentes. Por ejemplo, en Perl se puede escribir ($primero, $segundo, $tercero) = (20, 40, 60); La semántica es que 20 se asigna a $primero, 40 a $segundo y 60 a $tercero. Si hay que intercambiar los valores de dos variables, se puede hacer con una sola asignación, como con ($primero, $segundo) = ($segundo, $primero); Esto intercambia correctamente los valores de $first y $second, sin el uso de una variable temporal (al menos una creada y gestionada por el programador). La sintaxis de la forma más simple de la asignación múltiple de Ruby es similar a la de Perl, excepto los lados izquierdo y derecho no están entre paréntesis. Además, Ruby incluye algunas versiones más elaboradas de las asignaciones múltiples, que no se discuten aquí. 7.7.7 Asignación en lenguajes de programación funcional Todos los identificadores utilizados en los lenguajes funcionales puros y algunos de ellos utilizados en otros lenguajes funcionales son sólo nombres de valores. Como tales, sus valores nunca cambian. Por ejemplo, en ML, los nombres están ligados a valores con la declaración val, cuya forma se ejemplifica en lo siguiente: val coste = cantidad * precio; Si el coste aparece en el lado izquierdo de una declaración de val posterior, esa declaración crea una nueva versión del nombre coste, que no tiene relación con la versión anterior, que queda oculta. F# tiene una declaración algo similar que utiliza la palabra reservada let. La diferencia entre let de F# y val de ML es que let crea un nuevo ámbito, mientras que val no. De hecho, las declaraciones de val a menudo se anidan en construcciones de let en ML. let y val se discuten más a fondo en el Capítulo 15. 7.8 Asignación de modo mixto Las expresiones de modo mixto se trataron en el apartado 7.4.1. Con frecuencia, las sentencias de asignación también son de modo mixto. La cuestión de diseño es: ¿El tipo de la expresión tiene que ser el mismo que el tipo de la variable que se asigna, o puede utilizarse la coerción en algunos casos de desajuste de tipos? C, C++ y Perl utilizan reglas de coerción para la asignación de modo mixto que son similares a las que utilizan para las expresiones de modo mixto; es decir, muchas de las posibles mezclas de tipos son legales, con la coerción aplicada libremente. 7 En un claro alejamiento de C++, Java y C# permiten la asignación en modo mixto sólo si la coerción requerida se amplía. 8 Así, un valor int puede asignarse a una variable float, pero no a la inversa. Desautorizar la mitad de las posibles asignaciones de modo mixto es una forma sencilla pero eficaz de aumentar la fiabilidad de Java y C#, en relación con C y C++. Por supuesto, en los lenguajes funcionales, donde las asignaciones sólo se utilizan para nombrar valores, no existe la asignación de modo mixto. CUESTIONARIO 1. 2. 3. 4. 5. 6. 7. Definir la precedencia y la asociatividad de los operadores. ¿Qué es un operador unario? ¿Qué es un operador infijo? ¿Qué operador suele tener asociatividad izquierda? ¿Cuándo llamamos a los operadores "adyacentes"? ¿Cómo afectan los paréntesis a la regla de precedencia? Dar una solución al problema del orden de evaluación de los operandos y los efectos secundarios. 8. ¿Qué es un operador sobrecargado? 9. Definir las conversiones de estrechamiento y ensanchamiento. 10.¿Qué es una expresión de modo mixto? 11. ¿Cómo se relaciona la transparencia referencial con los efectos secundarios funcionales? 12.¿Cuáles son las ventajas de la transparencia referencial? 13.¿Cómo interactúa el orden de evaluación de los operandos con los efectos secundarios funcionales? 14.¿Qué es la evaluación del cortocircuito? 15.¿Para qué sirve un operador de asignación compuesta? 16.¿Cuál es una posible desventaja de tratar el operador de asignación como si fuera un operador aritmético?