|>
) para JavaScript (Este documento utiliza %
como token de marcador de posición para la referencia del tema. Es casi seguro que esta no será la opción final ; consulte la discusión sobre tokens para bicicletas para obtener más detalles).
En la encuesta State of JS 2020, la cuarta respuesta principal a "¿Qué crees que falta actualmente en JavaScript?" Era operador de tuberías . ¿Por qué?
Cuando realizamos operaciones consecutivas (por ejemplo, llamadas a funciones) sobre un valor en JavaScript, actualmente existen dos estilos fundamentales:
Es decir, three(two(one(value)))
versus value.one().two().three()
. Sin embargo, estos estilos difieren mucho en legibilidad, fluidez y aplicabilidad.
El primer estilo, anidamiento , es generalmente aplicable: funciona para cualquier secuencia de operaciones: llamadas a funciones, aritmética, literales de matriz/objeto, await
y yield
, etc.
Sin embargo, el anidamiento es difícil de leer cuando se vuelve profundo: el flujo de ejecución se mueve de derecha a izquierda , en lugar de la lectura de código normal de izquierda a derecha. Si hay múltiples argumentos en algunos niveles, la lectura incluso avanza y retrocede : nuestros ojos deben saltar hacia la izquierda para encontrar el nombre de una función, y luego deben saltar hacia la derecha para encontrar argumentos adicionales. Además, editar el código posteriormente puede resultar complicado: debemos encontrar el lugar correcto para insertar nuevos argumentos entre muchos paréntesis anidados .
Considere este código del mundo real de React.
console . log (
chalk . dim (
`$ ${ Object . keys ( envars )
. map ( envar =>
` ${ envar } = ${ envars [ envar ] } ` )
. join ( ' ' )
} ` ,
'node' ,
args . join ( ' ' ) ) ) ;
Este código del mundo real está formado por expresiones profundamente anidadas . Para poder leer su flujo de datos, los ojos de un humano primero deben:
Encuentre los datos iniciales (la expresión más interna, envars
).
Y luego escanee hacia adelante y hacia atrás repetidamente de adentro hacia afuera para cada transformación de datos, cada una de las cuales es un operador de prefijo que se pasa por alto fácilmente a la izquierda o un operador de sufijo a la derecha:
Object.keys()
(lado izquierdo),.map()
(lado derecho),.join()
(lado derecho),chalk.dim()
(lado izquierdo), luegoconsole.log()
(lado izquierdo).Como resultado de anidar profundamente muchas expresiones (algunas de las cuales usan operadores de prefijo , algunas usan operadores de postfijo y algunas usan operadores de circunfijo ), debemos verificar los lados izquierdo y derecho para encontrar el encabezado de cada expresión .
El segundo estilo, encadenamiento de métodos , sólo se puede utilizar si el valor tiene las funciones designadas como métodos para su clase. Esto limita su aplicabilidad. Pero cuando se aplica, gracias a su estructura postfix, generalmente es más utilizable y más fácil de leer y escribir. La ejecución del código fluye de izquierda a derecha . Se desenredan las expresiones profundamente anidadas. Todos los argumentos para una llamada a función se agrupan con el nombre de la función. Y editar el código más tarde para insertar o eliminar más llamadas a métodos es trivial, ya que simplemente tendríamos que colocar el cursor en un lugar y luego comenzar a escribir o eliminar una serie de caracteres contiguos .
De hecho, los beneficios del encadenamiento de métodos son tan atractivos que algunas bibliotecas populares contorsionan su estructura de código específicamente para permitir un mayor encadenamiento de métodos . El ejemplo más destacado es jQuery , que sigue siendo la biblioteca JS más popular del mundo. El diseño central de jQuery es un único superobjeto con docenas de métodos, todos los cuales devuelven el mismo tipo de objeto para que podamos continuar encadenando . Incluso existe un nombre para este estilo de programación: interfaces fluidas .
Desafortunadamente, a pesar de toda su fluidez, el encadenamiento de métodos por sí solo no puede adaptarse a otras sintaxis de JavaScript: llamadas a funciones, aritmética, literales de matriz/objeto, await
y yield
, etc. De esta manera, el encadenamiento de métodos sigue siendo limitado en su aplicabilidad .
El operador de tubería intenta combinar la conveniencia y facilidad del encadenamiento de métodos con la amplia aplicabilidad del anidamiento de expresiones .
La estructura general de todos los operadores de tubería es value |>
e1 |>
e2 |>
e3 , donde e1 , e2 , e3 son expresiones que toman valores consecutivos como parámetros. El operador |>
luego hace cierto grado de magia para "canalizar" value
desde el lado izquierdo al lado derecho.
Continuando con este código del mundo real profundamente anidado de React:
console . log (
chalk . dim (
`$ ${ Object . keys ( envars )
. map ( envar =>
` ${ envar } = ${ envars [ envar ] } ` )
. join ( ' ' )
} ` ,
'node' ,
args . join ( ' ' ) ) ) ;
…podemos desenredarlo como tal usando un operador de tubería y un token de marcador de posición ( %
) que reemplaza el valor de la operación anterior:
Object . keys ( envars )
. map ( envar => ` ${ envar } = ${ envars [ envar ] } ` )
. join ( ' ' )
| > `$ ${ % } `
| > chalk . dim ( % , 'node' , args . join ( ' ' ) )
| > console . log ( % ) ;
Ahora, el lector humano puede encontrar rápidamente los datos iniciales (lo que había sido la expresión más interna, envars
) y luego leer linealmente , de izquierda a derecha , cada transformación de los datos.
Se podría argumentar que el uso de variables temporales debería ser la única forma de desenredar el código profundamente anidado. Nombrar explícitamente la variable de cada paso provoca que suceda algo similar al encadenamiento de métodos, con beneficios similares a la lectura y escritura de código.
Por ejemplo, usando nuestro ejemplo anterior modificado del mundo real de React:
Object . keys ( envars )
. map ( envar => ` ${ envar } = ${ envars [ envar ] } ` )
. join ( ' ' )
| > `$ ${ % } `
| > chalk . dim ( % , 'node' , args . join ( ' ' ) )
| > console . log ( % ) ;
…una versión que use variables temporales se vería así:
const envarString = Object . keys ( envars )
. map ( envar => ` ${ envar } = ${ envars [ envar ] } ` )
. join ( ' ' ) ;
const consoleText = `$ ${ envarString } ` ;
const coloredConsoleText = chalk . dim ( consoleText , 'node' , args . join ( ' ' ) ) ;
console . log ( coloredConsoleText ) ;
Pero hay razones por las que en el mundo real nos encontramos todo el tiempo con expresiones profundamente anidadas en el código de cada uno de nosotros, en lugar de líneas de variables temporales. Y hay razones por las que las interfaces fluidas basadas en cadenas de métodos de jQuery, Mocha, etc. siguen siendo populares .
A menudo resulta simplemente demasiado tedioso y prolijo escribir código con una larga secuencia de variables temporales de un solo uso. Podría decirse que leer también es tedioso y visualmente ruidoso para un humano.
Si nombrar es una de las tareas más difíciles en programación, entonces los programadores inevitablemente evitarán nombrar variables cuando perciban que su beneficio es relativamente pequeño.
Se podría argumentar que el uso de una única variable mutable con un nombre corto reduciría la palabrería de las variables temporales, logrando resultados similares a los del operador de tubería.
Por ejemplo, nuestro ejemplo anterior modificado del mundo real de React podría reescribirse así:
let _ ;
_ = Object . keys ( envars )
. map ( envar => ` ${ envar } = ${ envars [ envar ] } ` )
. join ( ' ' ) ;
_ = `$ ${ _ } ` ;
_ = chalk . dim ( _ , 'node' , args . join ( ' ' ) ) ;
_ = console . log ( _ ) ;
Pero un código como este no es común en el código del mundo real. Una razón para esto es que las variables mutables pueden cambiar inesperadamente , provocando errores silenciosos que son difíciles de encontrar. Por ejemplo, es posible que se haga referencia accidentalmente a la variable en un cierre. O podría reasignarse por error dentro de una expresión.
// setup
function one ( ) { return 1 ; }
function double ( x ) { return x * 2 ; }
let _ ;
_ = one ( ) ; // _ is now 1.
_ = double ( _ ) ; // _ is now 2.
_ = Promise . resolve ( ) . then ( ( ) =>
// This does *not* print 2!
// It prints 1, because `_` is reassigned downstream.
console . log ( _ ) ) ;
// _ becomes 1 before the promise callback.
_ = one ( _ ) ;
Este problema no ocurriría con el operador de tuberías. El token de tema no se puede reasignar y el código fuera de cada paso no puede cambiar su enlace.
let _ ;
_ = one ( )
| > double ( % )
| > Promise . resolve ( ) . then ( ( ) =>
// This prints 2, as intended.
console . log ( % ) ) ;
_ = one ( ) ;
Por esta razón, el código con variables mutables también es más difícil de leer. Para determinar qué representa la variable en un punto determinado, debe buscar en todo el ámbito anterior los lugares donde se reasigna .
La referencia temática de una canalización, por otro lado, tiene un alcance léxico limitado y su vinculación es inmutable dentro de su alcance. No se puede reasignar accidentalmente y se puede utilizar de forma segura en cierres.
Aunque el valor del tema también cambia con cada paso del proceso, solo escaneamos el paso anterior del proceso para darle sentido, lo que genera un código que es más fácil de leer.
Otro beneficio del operador de tubería sobre secuencias de declaraciones de asignación (ya sea con variables temporales mutables o inmutables) es que son expresiones .
Las expresiones de canalización son expresiones que se pueden devolver directamente, asignar a una variable o usar en contextos como las expresiones JSX.
Por otro lado, el uso de variables temporales requiere secuencias de declaraciones.
Tuberías | Variables temporales |
---|---|
const envVarFormat = vars =>
Object . keys ( vars )
. map ( var => ` ${ var } = ${ vars [ var ] } ` )
. join ( ' ' )
| > chalk . dim ( % , 'node' , args . join ( ' ' ) ) ; | const envVarFormat = ( vars ) => {
let _ = Object . keys ( vars ) ;
_ = _ . map ( var => ` ${ var } = ${ vars [ var ] } ` ) ;
_ = _ . join ( ' ' ) ;
return chalk . dim ( _ , 'node' , args . join ( ' ' ) ) ;
} |
// This example uses JSX.
return (
< ul >
{
values
| > Object . keys ( % )
| > [ ... Array . from ( new Set ( % ) ) ]
| > % . map ( envar => (
< li onClick = {
( ) => doStuff ( values )
} > { envar } < / li >
) )
}
< / ul >
) ; | // This example uses JSX.
let _ = values ;
_ = Object . keys ( _ ) ;
_ = [ ... Array . from ( new Set ( _ ) ) ] ;
_ = _ . map ( envar => (
< li onClick = {
( ) => doStuff ( values )
} > { envar } < / li >
) ) ;
return (
< ul > { _ } < / ul >
) ; |
Había dos propuestas en competencia para el operador de tuberías: Hack pipes y F# pipes. (Antes de eso, hubo una tercera propuesta para una “mezcla inteligente” de las dos primeras propuestas, pero ha sido retirada, ya que su sintaxis es estrictamente un superconjunto de una de las propuestas).
Las dos propuestas de canalización simplemente difieren ligeramente en lo que es la "magia" cuando deletreamos nuestro código cuando usamos |>
.
Ambas propuestas reutilizan conceptos de lenguaje existentes: las canalizaciones Hack se basan en el concepto de expresión , mientras que las canalizaciones F# se basan en el concepto de función unaria .
Las expresiones de tubería y las funciones unarias de tubería tienen correspondientemente compensaciones pequeñas y casi simétricas.
En la sintaxis de canalización del lenguaje Hack , el lado derecho de la canalización es una expresión que contiene un marcador de posición especial, que se evalúa con el marcador de posición vinculado al resultado de evaluar la expresión del lado izquierdo. Es decir, escribimos value |> one(%) |> two(%) |> three(%)
para canalizar value
a través de las tres funciones.
Ventaja: el lado derecho puede ser cualquier expresión , y el marcador de posición puede ir a cualquier lugar donde pueda ir cualquier identificador de variable normal, por lo que podemos canalizar a cualquier código que queramos sin reglas especiales :
value |> foo(%)
para llamadas a funciones unarias,value |> foo(1, %)
para llamadas a funciones n-arias,value |> %.foo()
para llamadas a métodos,value |> % + 1
para aritmética,value |> [%, 0]
para literales de matriz,value |> {foo: %}
para objetos literales,value |> `${%}`
para literales de plantilla,value |> new Foo(%)
para construir objetos,value |> await %
para promesas en espera,value |> (yield %)
para obtener valores del generador,value |> import(%)
para llamar palabras clave similares a funciones, Desventaja: La canalización a través de funciones unarias es un poco más detallada con las canalizaciones Hack que con las canalizaciones F#. Esto incluye funciones unarias creadas por bibliotecas de funciones como Ramda, así como funciones de flecha unarias que realizan una desestructuración compleja en sus argumentos: Hack pipes sería un poco más detallado con un sufijo de llamada de función explícito (%)
.
(La desestructuración compleja del valor del tema será más fácil cuando las expresiones progresen, ya que entonces podrá realizar la asignación/desestructuración de variables dentro de un cuerpo de tubería).
En la sintaxis de canalización del lenguaje F# , el lado derecho de la canalización es una expresión que debe evaluarse en una función unaria , que luego se llama tácitamente con el valor del lado izquierdo como único argumento . Es decir, escribimos value |> one |> two |> three
para canalizar value
a través de las tres funciones. left |> right
se convierte en right(left)
. A esto se le llama programación tácita o estilo sin puntos.
Por ejemplo, usando nuestro ejemplo anterior modificado del mundo real de React:
Object . keys ( envars )
. map ( envar => ` ${ envar } = ${ envars [ envar ] } ` )
. join ( ' ' )
| > `$ ${ % } `
| > chalk . dim ( % , 'node' , args . join ( ' ' ) )
| > console . log ( % ) ;
…una versión que utilice canalizaciones de F# en lugar de canalizaciones de Hack se vería así:
Object . keys ( envars )
. map ( envar => ` ${ envar } = ${ envars [ envar ] } ` )
. join ( ' ' )
| > x => `$ ${ x } `
| > x => chalk . dim ( x , 'node' , args . join ( ' ' ) )
| > console . log ;
Ventaja: la restricción de que el lado derecho debe resolverse en una función unaria nos permite escribir canalizaciones muy concisas cuando la operación que queremos realizar es una llamada a una función unaria :
value |> foo
para llamadas a funciones unarias. Esto incluye funciones unarias creadas por bibliotecas de funciones como Ramda, así como funciones de flecha unarias que realizan una desestructuración compleja en sus argumentos: las canalizaciones de F# serían un poco menos detalladas con una llamada de función implícita (no (%)
).
Desventaja: la restricción significa que cualquier operación que se realice con otra sintaxis debe hacerse un poco más detallada envolviendo la operación en una función de flecha unaria:
value |> x=> x.foo()
para llamadas a métodos,value |> x=> x + 1
para aritmética,value |> x=> [x, 0]
para literales de matriz,value |> x=> ({foo: x})
para objetos literales,value |> x=> `${x}`
para literales de plantilla,value |> x=> new Foo(x)
para construir objetos,value |> x=> import(x)
para llamar palabras clave similares a funciones,Incluso llamar a funciones con nombre requiere ajustar cuando necesitamos pasar más de un argumento :
value |> x=> foo(1, x)
para llamadas a funciones n-arias. Desventaja: las operaciones await
y yield
tienen como alcance su función contenedora y, por lo tanto, no pueden manejarse únicamente con funciones unarias . Si queremos integrarlos en una expresión de canalización, await
y yield
deben manejarse como casos de sintaxis especiales :
value |> await
promesas en espera, yvalue |> yield
para generar valores del generador. Tanto Hack pipes como F# pipes imponen respectivamente un pequeño impuesto de sintaxis a diferentes expresiones:
Hack pipes grava ligeramente solo las llamadas a funciones unarias , y
Las canalizaciones de F# gravan ligeramente todas las expresiones excepto las llamadas a funciones unarias.
En ambas propuestas, el impuesto de sintaxis por expresión gravada es pequeño ( ambos (%)
y x=>
son solo tres caracteres ). Sin embargo, el impuesto se multiplica por la prevalencia de sus respectivas expresiones gravadas. Por lo tanto, podría tener sentido imponer un impuesto a las expresiones que sean menos comunes y optimizar a favor de las expresiones que sean más comunes .
Las llamadas a funciones unarias son, en general, menos comunes que todas las expresiones excepto las funciones unarias. En particular, la llamada a métodos y la llamada a funciones n-arias siempre serán populares ; en frecuencia general, la llamada a función unaria es igual o superada solo por esos dos casos, y mucho menos por otras sintaxis ubicuas como literales de matriz , literales de objeto y operaciones aritméticas . Esta explicación contiene varios ejemplos del mundo real de esta diferencia en la prevalencia.
Además, es probable que en el futuro también se generalicen otras sintaxis nuevas propuestas, como llamadas a extensiones , expresiones do y literales de registro/tupla . Asimismo, las operaciones aritméticas también serían aún más comunes si TC39 estandarizara la sobrecarga de operadores . Desenredar las expresiones de estas sintaxis futuras sería más fluido con las tuberías Hack en comparación con las tuberías F#.
El impuesto de sintaxis de Hack pipes en llamadas a funciones unarias (es decir, el (%)
para invocar la función unaria del lado derecho) no es un caso especial : simplemente se trata de escribir explícitamente código ordinario , de la manera que lo haríamos normalmente sin una pipe.
Por otro lado, las canalizaciones de F# requieren que distingamos entre “código que se resuelve en una función unaria” versus “cualquier otra expresión” , y que recordemos agregar el contenedor de función de flecha en este último caso.
Por ejemplo, con Hack pipes, value |> someFunction + 1
no tiene una sintaxis no válida y fallará antes de tiempo . No es necesario reconocer que someFunction + 1
no se evaluará como una función unaria. Pero con las canalizaciones de F#, value |> someFunction + 1
sigue siendo una sintaxis válida ; simplemente fallará tarde en tiempo de ejecución , porque someFunction + 1
no es invocable.
El grupo campeón de pipas ha presentado dos veces pipas F# para la Etapa 2 al TC39. En ambas ocasiones no logró avanzar a la Etapa 2. Ambas canalizaciones de F# (y la aplicación de función parcial (PFA)) se han topado con un fuerte rechazo por parte de muchos otros representantes de TC39 debido a diversas preocupaciones. Estos han incluido:
await
.Este retroceso se ha producido desde fuera del grupo de campeones de la tubería. Consulte HISTORY.md para obtener más información.
El grupo de campeones de tuberías cree que cualquier operador de tuberías es mejor que ninguno, para linealizar fácilmente expresiones profundamente anidadas sin recurrir a variables con nombre. Muchos miembros del grupo campeón creen que las tuberías Hack son ligeramente mejores que las tuberías F#, y algunos miembros del grupo campeón creen que las tuberías F# son ligeramente mejores que las tuberías Hack. Pero todos en el grupo de campeones están de acuerdo en que las tuberías F# han encontrado demasiada resistencia para poder pasar TC39 en el futuro previsible.
Para enfatizar, es probable que un intento de cambiar de las tuberías Hack a las tuberías F# resulte en que TC39 nunca acepte ninguna tubería. La sintaxis de PFA también enfrenta una batalla cuesta arriba en TC39 (ver HISTORY.md). Muchos miembros del grupo de campeones de pipe piensan que esto es desafortunado y están dispuestos a luchar nuevamente más tarde por una mezcla dividida de F#-pipe y sintaxis PFA. Pero hay bastantes representantes (incluidos los implementadores de motores de navegador) fuera del Pipe Champion Group que generalmente están en contra de fomentar la programación tácita (y la sintaxis PFA), independientemente de Hack pipes.
(Hay disponible un borrador de especificación formal).
El %
de referencia del tema es un operador nulo . Actúa como marcador de posición para un valor de tema y tiene alcance léxico y es inmutable .
%
no es una elección final (El token preciso para la referencia del tema no es definitivo . %
podría ser ^
, o muchos otros tokens. Planeamos determinar qué token real usar antes de avanzar a la Etapa 3. Sin embargo, %
parece ser el menos problemático sintácticamente, y también se parece a los marcadores de posición de las cadenas en formato printf y a los literales de la función #(%)
de Clojure ).
El operador de tubería |>
es un operador infijo que forma una expresión de tubería (también llamada tubería ). Evalúa su lado izquierdo (la cabeza de la tubería o la entrada de la tubería ), vincula de manera inmutable el valor resultante (el valor del tema ) a la referencia del tema y luego evalúa su lado derecho (el cuerpo de la tubería ) con esa vinculación. El valor resultante del lado derecho se convierte en el valor final de toda la expresión de canalización (la salida de canalización ).
La precedencia del operador de tubería es la misma que:
=>
;=
, +=
, etc.;yield
y yield *
; Es más estricto que solo el operador de coma ,
Es más flexible que todos los demás operadores.
Por ejemplo, v => v |> % == null |> foo(%, 0)
se agruparía en v => (v |> (% == null) |> foo(%, 0))
,
que a su vez es equivalente a v => foo(v == null, 0)
.
Un cuerpo de tubería debe utilizar su valor de tema al menos una vez . Por ejemplo, value |> foo + 1
no tiene una sintaxis no válida porque su cuerpo no contiene una referencia de tema. Este diseño se debe a que la omisión de la referencia del tema en el cuerpo de una expresión vertical es casi con certeza un error accidental del programador.
Asimismo, una referencia de tema debe estar contenida en un cuerpo de tubería. Usar una referencia de tema fuera del cuerpo de una tubería tampoco es una sintaxis no válida .
Para evitar una agrupación confusa, no es válido el uso de otros operadores que tengan precedencia similar (es decir, la flecha =>
, el operador condicional ternario ?
:
, los operadores de asignación y el operador yield
) como cabeza o cuerpo de tubería . Al usar |>
con estos operadores, debemos usar paréntesis para indicar explícitamente qué agrupación es correcta. Por ejemplo, a |> b ? % : c |> %.d
no tiene una sintaxis no válida; debe corregirse a a |> (b ? % : c) |> %.d
o a |> (b ? % : c |> %.d)
.
Por último, los enlaces de temas dentro del código compilado dinámicamente (por ejemplo, con eval
o new Function
) no se pueden usar fuera de ese código. Por ejemplo, v |> eval('% + 1')
generará un error de sintaxis cuando la expresión eval
se evalúe en tiempo de ejecución.
No hay otras reglas especiales .
Un resultado natural de estas reglas es que, si necesitamos interponer un efecto secundario en medio de una cadena de expresiones de canalización, sin modificar los datos que se canalizan, entonces podríamos usar una expresión de coma , como con value |> (sideEffect(), %)
. Como de costumbre, la expresión de coma se evaluará en el lado derecho %
, esencialmente pasando por el valor del tema sin modificarlo. Esto es especialmente útil para una depuración rápida: value |> (console.log(%), %)
.
Los únicos cambios en los ejemplos originales fueron la sangría y la eliminación de comentarios.
Desde jquery/build/tasks/sourceMap.js:
// Status quo
var minLoc = Object . keys ( grunt . config ( "uglify.all.files" ) ) [ 0 ] ;
// With pipes
var minLoc = grunt . config ( 'uglify.all.files' ) | > Object . keys ( % ) [ 0 ] ;
Desde nodo/deps/npm/lib/unpublish.js:
// Status quo
const json = await npmFetch . json ( npa ( pkgs [ 0 ] ) . escapedName , opts ) ;
// With pipes
const json = pkgs [ 0 ] | > npa ( % ) . escapedName | > await npmFetch . json ( % , opts ) ;
De subrayado.js:
// Status quo
return filter ( obj , negate ( cb ( predicate ) ) , context ) ;
// With pipes
return cb ( predicate ) | > _ . negate ( % ) | > _ . filter ( obj , % , context ) ;
De ramda.js.