¿Por qué C # se creó con las palabras clave "nuevo" y "virtual + anulación" a diferencia de Java?

61

En Java no existen virtual, new, overridepalabras clave para la definición del método. Entonces, el funcionamiento de un método es fácil de entender. Porque si DerivedClass extiende BaseClass y tiene un método con el mismo nombre y la misma firma de BaseClass , la anulación tendrá lugar en el polimorfismo en tiempo de ejecución (siempre que el método no lo sea static).

BaseClass bcdc = new DerivedClass(); 
bcdc.doSomething() // will invoke DerivedClass's doSomething method.

Ahora vienen a C # no puede haber tanta confusión y difícil de entender cómo el newo virtual+deriveo nueva + Virtual de anulación está trabajando.

No puedo entender por qué en el mundo voy a agregar un método en mi DerivedClasscon el mismo nombre y la misma firma BaseClassy definir un nuevo comportamiento, pero en el polimorfismo en tiempo de ejecución, ¡ BaseClassse invocará el método! (que no está anulando pero lógicamente debería estarlo).

En caso de virtual + overrideque la implementación lógica sea correcta, pero el programador debe pensar qué método debe dar permiso al usuario para anular en el momento de la codificación. Que tiene algo de pro-con (no vayamos allí ahora).

Entonces, ¿por qué en C # hay tanto espacio para el razonamiento no lógico y la confusión? Así que puedo replantear mi pregunta en qué contexto del mundo real debería pensar en su uso virtual + overrideen lugar de newy también el uso de newen lugar de virtual + override?


Después de algunas muy buenas respuestas, especialmente de Omar , entiendo que los diseñadores de C # le dieron más énfasis a los programadores que deberían pensar antes de hacer un método, lo cual es bueno y maneja algunos errores de novatos de Java.

Ahora tengo una pregunta en mente. Como en Java si tuviera un código como

Vehicle vehicle = new Car();
vehicle.accelerate();

y luego hago una nueva clase SpaceShipderivada de Vehicle. Entonces quiero cambiar todo cara un SpaceShipobjeto, solo tengo que cambiar una sola línea de código

Vehicle vehicle = new SpaceShip();
vehicle.accelerate();

Esto no romperá nada de mi lógica en ningún punto del código.

Pero en el caso de C # si SpaceShipno anula la Vehicleclase acceleratey el uso, newentonces la lógica de mi código se romperá. ¿No es eso una desventaja?

Anirban Nag 'tintinmj'
fuente
77
Simplemente está acostumbrado a la forma en que Java lo hace, y simplemente no se ha tomado el tiempo para comprender las palabras clave de C # en sus propios términos. Trabajo con C #, entendí los términos de inmediato y encuentro extraño la forma en que Java lo hace.
Robert Harvey
12
IIRC, C # hizo esto para "favorecer la claridad". Si debe decir explícitamente "nuevo" o "anular", deja en claro y de inmediato lo que está sucediendo, en lugar de tener que perder el tiempo tratando de averiguar si el método está anulando algún comportamiento en una clase base o no. También me resulta muy útil poder especificar qué métodos quiero especificar como virtuales y cuáles no. (Java hace esto con final; es todo lo contrario).
Robert Harvey
15
"En Java no hay palabras clave virtuales, nuevas y de anulación para la definición del método". Excepto que sí @Override.
svick
3
Anotación @svick y las palabras clave no son lo mismo :)
Anirban Nag 'tintinmj'

Respuestas:

86

Dado que usted preguntó por qué C # lo hizo de esta manera, es mejor preguntar a los creadores de C #. Anders Hejlsberg, el arquitecto principal de C #, respondió por qué eligieron no usar el virtual por defecto (como en Java) en una entrevista , los fragmentos pertinentes están a continuación.

Tenga en cuenta que Java tiene virtual de forma predeterminada con la palabra clave final para marcar un método como no virtual. Todavía hay dos conceptos que aprender, pero muchas personas no conocen la palabra clave final o no la usan de manera proactiva. C # obliga a uno a usar virtual y new / override para tomar conscientemente esas decisiones.

Hay varias razones. Uno es el rendimiento . Podemos observar que a medida que las personas escriben código en Java, se olvidan de marcar sus métodos como finales. Por lo tanto, esos métodos son virtuales. Como son virtuales, no funcionan tan bien. Solo hay una sobrecarga de rendimiento asociada con ser un método virtual. Ese es un problema.

Un tema más importante es el versionado . Hay dos escuelas de pensamiento sobre los métodos virtuales. La escuela de pensamiento académica dice: "Todo debería ser virtual, porque algún día querré anularlo". La escuela pragmática de pensamiento, que proviene de la construcción de aplicaciones reales que se ejecutan en el mundo real, dice: "Tenemos que tener mucho cuidado con lo que hacemos virtual".

Cuando hacemos algo virtual en una plataforma, hacemos muchas promesas sobre cómo evolucionará en el futuro. Para un método no virtual, prometemos que cuando llame a este método, x e y sucederán. Cuando publicamos un método virtual en una API, no solo prometemos que cuando llame a este método, x e y sucederán. También prometemos que cuando anule este método, lo llamaremos en esta secuencia particular con respecto a estos otros y el estado estará en esto y aquello invariante.

Cada vez que dices virtual en una API, estás creando un enlace de devolución de llamada. Como diseñador de marcos de SO o API, debes tener mucho cuidado con eso. No desea que los usuarios anulen y enganchen en ningún punto arbitrario en una API, porque no necesariamente puede hacer esas promesas. Y las personas pueden no entender completamente las promesas que están haciendo cuando hacen algo virtual.

La entrevista tiene más discusión sobre cómo piensan los desarrolladores sobre el diseño de herencia de clase, y cómo eso llevó a su decisión.

Ahora a la siguiente pregunta:

No puedo entender por qué en el mundo voy a agregar un método en mi DerivedClass con el mismo nombre y la misma firma que BaseClass y definir un nuevo comportamiento, pero en el polimorfismo en tiempo de ejecución, ¡se invocará el método BaseClass! (que no está anulando pero lógicamente debería estarlo).

Esto sería cuando una clase derivada quiere declarar que no cumple con el contrato de la clase base, sino que tiene un método con el mismo nombre. (Para cualquiera que no sepa la diferencia entre newy overrideen C #, consulte esta página de MSDN ).

Un escenario muy práctico es este:

  • Creó una API, que tiene una clase llamada Vehicle.
  • Empecé a usar su API y derivado Vehicle.
  • Tu Vehicleclase no tenía ningún método PerformEngineCheck().
  • En mi Carclase, agrego un método PerformEngineCheck().
  • Lanzaste una nueva versión de tu API y agregaste un PerformEngineCheck().
  • No puedo cambiar el nombre de mi método porque mis clientes dependen de mi API y eso los rompería.
  • Entonces, cuando vuelvo a compilar contra su nueva API, C # me advierte sobre este problema, por ejemplo

    Si la base PerformEngineCheck()no era virtual:

    app2.cs(15,17): warning CS0108: 'Car.PerformEngineCheck()' hides inherited member 'Vehicle.PerformEngineCheck()'.
    Use the new keyword if hiding was intended.

    Y si la base PerformEngineCheck()era virtual:

    app2.cs(15,17): warning CS0114: 'Car.PerformEngineCheck()' hides inherited member 'Vehicle.PerformEngineCheck()'.
    To make the current member override that implementation, add the override keyword. Otherwise add the new keyword.
  • Ahora, debo tomar una decisión explícita si mi clase realmente está extendiendo el contrato de la clase base, o si es un contrato diferente pero resulta ser el mismo nombre.

  • Al hacerlo new, no rompo a mis clientes si la funcionalidad del método base era diferente del método derivado. Cualquier código al que se Vehiclehaga referencia no se verá Car.PerformEngineCheck()llamado, pero el código al que hizo referencia Carcontinuará viendo la misma funcionalidad que yo había ofrecido PerformEngineCheck().

Un ejemplo similar es cuando otro método en la clase base podría estar llamando PerformEngineCheck()(especialmente en la versión más reciente), ¿cómo se evita que llame a la PerformEngineCheck()clase derivada? En Java, esa decisión dependería de la clase base, pero no sabe nada sobre la clase derivada. En C #, esa decisión se basa tanto en la clase base (a través de la virtualpalabra clave) como en la clase derivada (a través de las palabras clave newy override).

Por supuesto, los errores que arroja el compilador también proporcionan una herramienta útil para que los programadores no cometan errores inesperadamente (es decir, anular o proporcionar una nueva funcionalidad sin darse cuenta).

Como dijo Anders, el mundo real nos obliga a tales problemas que, si comenzáramos desde cero, nunca quisiéramos entrar.

EDITAR: se agregó un ejemplo de dónde newdebería usarse para garantizar la compatibilidad de la interfaz.

EDITAR: Mientras revisaba los comentarios, también encontré un artículo escrito por Eric Lippert (entonces uno de los miembros del comité de diseño de C #) sobre otros escenarios de ejemplo (mencionados por Brian).


PARTE 2: Basado en una pregunta actualizada

Pero en el caso de C # si SpaceShip no anula la aceleración de la clase Vehículo y usa una nueva, entonces la lógica de mi código se romperá. ¿No es eso una desventaja?

¿Quién decide si SpaceShiprealmente está anulando Vehicle.accelerate()o si es diferente? Tiene que ser el SpaceShipdesarrollador. Entonces, si el SpaceShipdesarrollador decide que no está cumpliendo con el contrato de la clase base, entonces su llamada a Vehicle.accelerate()no debería ir SpaceShip.accelerate(), ¿o debería? Es entonces cuando lo marcarán como new. Sin embargo, si deciden que efectivamente mantiene el contrato, lo marcarán override. En cualquier caso, su código se comportará correctamente llamando al método correcto según el contrato . ¿Cómo puede su código decidir si SpaceShip.accelerate()realmente está anulando Vehicle.accelerate()o si es una colisión de nombres? (Ver mi ejemplo arriba).

Sin embargo, en el caso de la herencia implícita, aunque SpaceShip.accelerate()no se mantenga el contrato de Vehicle.accelerate(), la llamada al método todavía ir a SpaceShip.accelerate().

Omer Iqbal
fuente
12
El punto de rendimiento es completamente obsoleto por ahora. Para una prueba, vea mi punto de referencia que muestra que acceder a un campo a través de un método no final pero nunca sobrecargado requiere un solo ciclo.
maaartinus
77
Claro, ese podría ser el caso. La pregunta era que cuando C # decidió hacerlo, ¿por qué lo hizo EN ESE momento y, por lo tanto, esta respuesta es válida? Si la pregunta es si todavía tiene sentido, esa es una discusión diferente, en mi humilde opinión.
Omer Iqbal
1
Estoy totalmente de acuerdo con usted.
maaartinus
2
En mi humilde opinión, aunque definitivamente hay usos para tener funciones no virtuales, el riesgo de errores inesperados ocurre cuando algo que no se espera anule un método de clase base o implemente una interfaz, lo hace.
supercat
3
@Nilzor: De ahí el argumento sobre lo académico versus lo pragmático. Pragmáticamente, la responsabilidad de romper algo recae en el último creador de cambios. Si cambia su clase base, y una clase derivada existente depende de que no cambie, ese no es su problema ("ellos" ya no pueden existir). Por lo tanto, las clases base se bloquean en el comportamiento de sus clases derivadas, incluso si esa clase nunca debería haber sido virtual . Y como dices, RhinoMock existe. Sí, si finaliza correctamente todos los métodos, todo es equivalente. Aquí señalamos todos los sistemas Java jamás construidos.
deworde
94

Se hizo porque es lo correcto. El hecho es que permitir que se anulen todos los métodos es incorrecto; conduce al problema de la clase base frágil, donde no tienes forma de saber si un cambio en la clase base romperá las subclases. Por lo tanto, debe incluir en la lista negra los métodos que no se deben anular o incluir en la lista blanca los que se pueden anular. De los dos, la lista blanca no solo es más segura (ya que no puede crear una clase base frágil accidentalmente ), sino que también requiere menos trabajo ya que debe evitar la herencia a favor de la composición.

Doval
fuente
77
Permitir que se anule cualquier método arbitrario es incorrecto. Solo debe poder anular los métodos que el diseñador de superclase ha designado como seguros para anular.
Doval
21
Eric Lippert analiza esto en detalle en su publicación, Métodos virtuales y Clases base frágiles .
Brian
12
Java tiene finalmodificador, entonces, ¿cuál es el problema?
Sarge Borsch
13
@corsiKa "los objetos deben estar abiertos a la extensión" - ¿por qué? Si el desarrollador de la clase base nunca pensó en la herencia, está casi garantizado que existen sutiles dependencias y suposiciones en el código que conducirán a errores ( HashMap¿ recuerda ?). Diseñe para la herencia y aclare el contrato o no, y asegúrese de que nadie pueda heredar la clase. @Overridese agregó como bandaid porque ya era demasiado tarde para cambiar el comportamiento, pero incluso los desarrolladores originales de Java (particularmente Josh Bloch) están de acuerdo en que esta fue una mala decisión.
Voo
99
@jwenting "Opinión" es una palabra divertida; a la gente le gusta adjuntarlo a los hechos para poder ignorarlos. Pero para lo que sea que valga, también es la "opinión" de Joshua Bloch (ver: Java Efectivo , Artículo 17 ), la "opinión" de James Gosling (ver esta entrevista ), y como se señala en la respuesta de Omer Iqbal , también es la "opinión" de Anders Hejlsberg. (Y aunque nunca abordó la opción de optar por no participar, Eric Lippert claramente está de acuerdo en que la herencia también es peligrosa). Entonces, ¿a quién se refieren?
Doval
33

Como dijo Robert Harvey, todo está en lo que estás acostumbrado. Me parece extraña la falta de flexibilidad de Java .

Dicho esto, ¿por qué tener esto en primer lugar? Por la misma razón por la que C # tiene public, internal(también "nada"), protected, protected internal, y private, aunque apenas tiene Java public, protected, nada, y private. Proporciona un control de grano más fino sobre el comportamiento de lo que está codificando, a expensas de tener más términos y palabras clave para realizar un seguimiento.

En el caso de newvs. virtual+override, va más o menos así:

  • Si desea forzar a las subclases a implementar el método, use abstracty overrideen la subclase.
  • Si desea proporcionar funcionalidad pero permite que la subclase la reemplace, use virtualy overrideen la subclase.
  • Si desea proporcionar una funcionalidad que las subclases nunca deberían necesitar anular, no use nada.
    • Si usted entonces tiene una subclase caso especial que no tiene que comportarse de manera diferente, utilice newen la subclase.
    • Si desea asegurarse de que ninguna subclase puede jamás sustituir el comportamiento, utilizar sealeden la clase base.

Para un ejemplo del mundo real: un proyecto en el que trabajé en pedidos de comercio electrónico procesados ​​de muchas fuentes diferentes. Había una base OrderProcessorque tenía la mayor parte de la lógica, con ciertos métodos abstract/ virtualpara anular la clase secundaria de cada fuente. Esto funcionó bien, hasta que obtuvimos una nueva fuente que tenía una forma completamente diferente de procesar pedidos, de modo que tuvimos que reemplazar una función central. Teníamos dos opciones en este punto: 1) Agregar virtualal método base, y overrideen el niño; o 2) Agregar newal niño.

Si bien cualquiera de los dos podría funcionar, el primero haría que sea muy fácil anular ese método en particular nuevamente en el futuro. Aparecería en autocompletar, por ejemplo. Sin embargo, este fue un caso excepcional, por lo que elegimos usarlo newen su lugar. Eso preservó el estándar de "este método no necesita ser anulado", al tiempo que permite el caso especial donde lo hizo. Es una diferencia semántica que hace la vida más fácil.

Ten en cuenta, sin embargo, que no es una diferencia de comportamiento asociado con esto, no sólo una diferencia semántica. Vea este artículo para más detalles. Sin embargo, nunca me he encontrado con una situación en la que necesitaba aprovechar este comportamiento.

Bobson
fuente
77
Creo que usarlo intencionalmente de newesta manera es un error que espera suceder. Si llamo a un método en alguna instancia, espero que siempre haga lo mismo, incluso si lanzo la instancia a su clase base. Pero no es así como se newcomportan los métodos ed.
svick
@svick - Potencialmente, sí. En nuestro escenario particular, eso nunca ocurriría, pero es por eso que agregué la advertencia.
Bobson
Un uso excelente de newes en WebViewPage <TModel> en el marco MVC. Pero también he sido arrojado por un error que involucra newhoras, por lo que no creo que sea una pregunta irrazonable.
pdr
1
@ C.Champagne: cualquier herramienta se puede usar mal y esta es una herramienta particularmente afilada: puede cortarse fácilmente. Pero esa no es una razón para eliminar la herramienta de la caja de herramientas y eliminar una opción de un diseñador de API más talentoso.
pdr
1
@svick Correcto, por lo que normalmente es una advertencia del compilador. Pero tener la capacidad de "nuevo" cubre las condiciones de borde (como la dada), y aún mejor, hace que sea realmente obvio que estás haciendo algo extraño cuando diagnosticas el error inevitable. "¿Por qué esta clase y solo esta clase tiene errores ... ah-hah, un" nuevo ", vamos a probar dónde se usa la Superclase".
deworde
7

El diseño de Java es tal que, dada cualquier referencia a un objeto, una llamada a un nombre de método particular con tipos de parámetros particulares, si está permitido, siempre invocará el mismo método. Es posible que las conversiones implícitas de tipo de parámetro puedan verse afectadas por el tipo de referencia, pero una vez que se han resuelto todas esas conversiones, el tipo de referencia es irrelevante.

Esto simplifica el tiempo de ejecución, pero puede causar algunos problemas desafortunados. Supongamos GrafBaseque no implementa void DrawParallelogram(int x1,int y1, int x2,int y2, int x3,int y3), pero lo GrafDerivedimplementa como un método público que dibuja un paralelogramo cuyo cuarto punto calculado es opuesto al primero. Supongamos además que una versión posterior de GrafBaseimplementa un método público con la misma firma, pero su cuarto punto calculado frente al segundo. Los clientes que reciben esperan un GrafBasepero reciben una referencia a un GrafDerivedesperarán DrawParallelogramcalcular el cuarto punto en la forma del nuevo GrafBasemétodo, pero los clientes que han estado usando GrafDerived.DrawParallelogramantes de que se cambiara el método base esperarán el comportamiento que GrafDerivedse implementó originalmente.

En Java, no habría forma de que el autor GrafDerivedhaga que esa clase coexista con clientes que usan el nuevo GrafBase.DrawParallelogrammétodo (y pueden no darse cuenta de que GrafDerivedincluso existe) sin romper la compatibilidad con el código de cliente existente que usó GrafDerived.DrawParallelogramantes de GrafBasedefinirlo. Dado DrawParallelogramque no se puede saber qué tipo de cliente lo invoca, debe comportarse de manera idéntica cuando se invoca mediante tipos de código de cliente. Dado que los dos tipos de código de cliente tienen expectativas diferentes sobre cómo debería comportarse, no hay forma de GrafDerivedevitar violar las expectativas legítimas de uno de ellos (es decir, romper el código de cliente legítimo).

En C #, si GrafDerivedno se vuelve a compilar, el tiempo de ejecución asumirá que el código que invoca el DrawParallelogrammétodo sobre referencias de tipo GrafDerivedesperará el comportamiento que GrafDerived.DrawParallelogram()tuvo cuando se compiló por última vez, pero el código que invoca el método sobre referencias de tipo GrafBaseesperará GrafBase.DrawParallelogram(el comportamiento que fue añadido). Si GrafDerivedluego se vuelve a compilar en presencia del mejorado GrafBase, el compilador graznará hasta que el programador especifique si su método fue un reemplazo válido para el miembro heredado GrafBase, o si su comportamiento debe estar vinculado a referencias de tipo GrafDerived, pero no debe reemplazar el comportamiento de referencias de tipo GrafBase.

Se podría argumentar razonablemente que tener un método para GrafDerivedhacer algo diferente de un miembro GrafBaseque tiene la misma firma indicaría un mal diseño y, como tal, no debería ser compatible. Desafortunadamente, dado que el autor de un tipo base no tiene forma de saber qué métodos pueden agregarse a los tipos derivados, ni viceversa, la situación en la que los clientes de clase base y clase derivada tienen expectativas diferentes para métodos con nombres similares es esencialmente inevitable a menos que nadie tiene permitido agregar ningún nombre que alguien más pueda agregar. La pregunta no es si tal duplicación de nombre debería ocurrir, sino cómo minimizar el daño cuando ocurre.

Super gato
fuente
2
Creo que hay una buena respuesta aquí, pero se está perdiendo en el muro de texto. Divida esto en unos pocos párrafos más.
Bobson
¿Qué es DerivedGraphics? A class using GrafDerived`?
C.Champagne
@ C.Champagne: quise decir GrafDerived. Empecé a usar DerivedGraphics, pero sentí que era un poco largo. Even GrafDerivedaún es un poco largo, pero no sabía cómo nombrar mejor los tipos de renderizador de gráficos que deberían tener una relación clara de base / derivada.
supercat
1
@Bobson: ¿Mejor?
supercat
6

Situación estándar:

Eres el propietario de una clase base que utilizan varios proyectos. Desea realizar un cambio en dicha clase base que dividirá entre 1 e innumerables clases derivadas, que se encuentran en proyectos que brindan valor en el mundo real (un marco proporciona valor en el mejor de los casos, un ser humano real quiere un marco, ellos quieren lo que se ejecuta en el Marco). Buena suerte para los ocupados propietarios de las clases derivadas; "bueno, tienes que cambiar, no deberías haber anulado ese método" sin obtener un representante como "Marco: Retraso de proyectos y Causador de errores" para las personas que tienen que aprobar decisiones.

Especialmente porque, al no declarar que no se puede anular, implícitamente has declarado que estaban bien hacer lo que ahora impide tu cambio.

Y si no tiene un número significativo de clases derivadas que brinden valor real al anular su clase base, ¿por qué es una clase base en primer lugar? La esperanza es un poderoso motivador, pero también una muy buena manera de terminar con un código sin referencia.

Resultado final: su código de clase base de framework se vuelve increíblemente frágil y estático, y realmente no puede hacer los cambios necesarios para mantenerse actualizado / eficiente. Alternativamente, su marco de trabajo obtiene un representante de la inestabilidad (las clases derivadas se siguen rompiendo) y la gente no lo usará en absoluto, ya que la razón principal para usar un marco es hacer que la codificación sea más rápida y confiable.

En pocas palabras, no puede pedirles a los propietarios de proyectos ocupados que retrasen su proyecto para corregir los errores que está presentando y esperar algo mejor que un "desaparecer" a menos que les brinde beneficios significativos, incluso si la "falla" original era de ellos, lo cual es discutible en el mejor de los casos.

Es mejor no dejar que hagan lo incorrecto en primer lugar, que es donde entra en juego "no virtual por defecto". Y cuando alguien se acerca a usted con una razón muy clara de por qué necesitan que este método en particular sea reemplazable, y por qué debe ser seguro, puede "desbloquearlo" sin correr el riesgo de romper el código de otra persona.

deworde
fuente
0

Por defecto a no virtual se supone que el desarrollador de la clase base es perfecto. En mi experiencia, los desarrolladores no son perfectos. Si el desarrollador de una clase base no puede imaginar un caso de uso en el que un método podría ser anulado u olvida agregar virtual, entonces no puedo aprovechar el polimorfismo al extender la clase base sin modificar la clase base. En el mundo real, modificar la clase base a menudo no es una opción.

En C #, el desarrollador de la clase base no confía en el desarrollador de la subclase. En Java, el desarrollador de la subclase no confía en el desarrollador de la clase base. El desarrollador de la subclase es responsable de la subclase y debería (en mi humilde opinión) tener el poder de extender la clase base como mejor le parezca (salvo la negación explícita, y en Java incluso pueden equivocarse).

Es una propiedad fundamental de la definición del lenguaje. No es correcto o incorrecto, es lo que es y no puede cambiar.

clish
fuente
2
esto no parece ofrecer nada sustancial sobre los puntos realizados y explicados en anteriores 5 respuestas
mosquito