¿Cómo dividir una cadena en una matriz en Bash?

Resuelto Lgn asked hace 12 años • 25 respuestas

En un script Bash, me gustaría dividir una línea en partes y almacenarlas en una matriz.

Por ejemplo, dada la línea:

Paris, France, Europe

Me gustaría que la matriz resultante se viera así:

array[0] = Paris
array[1] = France
array[2] = Europe

Es preferible una implementación sencilla; la velocidad no importa. ¿Cómo puedo hacerlo?

Lgn avatar May 14 '12 22:05 Lgn
Aceptado
IFS=', ' read -r -a array <<< "$string"

Tenga en cuenta que los caracteres $IFSse tratan individualmente como separadores, por lo que en este caso los campos pueden estar separados por una coma o un espacio en lugar de la secuencia de los dos caracteres. Sin embargo, es interesante que los campos vacíos no se crean cuando aparece un espacio de coma en la entrada porque el espacio se trata de manera especial.

Para acceder a un elemento individual:

echo "${array[0]}"

Para iterar sobre los elementos:

for element in "${array[@]}"
do
    echo "$element"
done

Para obtener tanto el índice como el valor:

for index in "${!array[@]}"
do
    echo "$index ${array[index]}"
done

El último ejemplo es útil porque las matrices Bash son escasas. En otras palabras, puede eliminar un elemento o agregar un elemento y luego los índices no son contiguos.

unset "array[1]"
array[42]=Earth

Para obtener la cantidad de elementos en una matriz:

echo "${#array[@]}"

Como se mencionó anteriormente, las matrices pueden ser escasas, por lo que no debes usar la longitud para obtener el último elemento. Así es como puedes hacerlo en Bash 4.2 y versiones posteriores:

echo "${array[-1]}"

en cualquier versión de Bash (desde algún lugar posterior a 2.05b):

echo "${array[@]: -1:1}"

Los desplazamientos negativos más grandes se seleccionan más lejos del final de la matriz. Tenga en cuenta el espacio antes del signo menos en el formulario anterior. Es requerido.

Dennis Williamson avatar May 14 '2012 15:05 Dennis Williamson

Todas las respuestas a esta pregunta son erróneas de una forma u otra.


Respuesta incorrecta #1

IFS=', ' read -r -a array <<< "$string"

1: Este es un mal uso de $IFS. El valor de la $IFSvariable no se toma como un único separador de cadena de longitud variable, sino como un conjunto de separadores de cadena de un solo carácter , donde cada campo que readse separa de la línea de entrada puede terminar con cualquier carácter del conjunto. (coma o espacio, en este ejemplo).

En realidad, para los verdaderos rigurosos, el significado completo de $IFSes un poco más complicado. Del manual de bash :

El shell trata cada carácter de IFS como un delimitador y divide los resultados de las otras expansiones en palabras utilizando estos caracteres como terminadores de campo. Si IFS no está configurado, o su valor es exactamente <space><tab><newline> , el valor predeterminado, entonces secuencias de <space> , <tab> y <newline> al principio y al final de los resultados de las expansiones anteriores se ignoran y cualquier secuencia de caracteres IFS que no estén al principio o al final sirve para delimitar palabras. Si IFS tiene un valor distinto al predeterminado, las secuencias de caracteres de espacio en blanco <espacio> , <tab> y <nueva línea> se ignoran al principio y al final de la palabra, siempre que el carácter de espacio en blanco esté en el valor de IFS (un carácter de espacio en blanco IFS ). Cualquier carácter en IFS que no sea un espacio en blanco IFS , junto con cualquier carácter de espacio en blanco IFS adyacente, delimita un campo. Una secuencia de caracteres de espacios en blanco IFS también se trata como un delimitador. Si el valor de IFS es nulo, no se produce ninguna división de palabras.

Básicamente, para valores no nulos no predeterminados de $IFS, los campos se pueden separar con (1) una secuencia de uno o más caracteres que son todos del conjunto de "caracteres de espacios en blanco IFS" (es decir, cualquiera de <espacio> , <tab> y <nueva línea> ("nueva línea" significa avance de línea (LF) ) están presentes en cualquier lugar de $IFS), o (2) cualquier carácter de espacio en blanco que no sea IFS que esté presente junto $IFScon los "caracteres de espacio en blanco IFS" que lo rodean. en la línea de entrada.

Para el OP, es posible que el segundo modo de separación que describí en el párrafo anterior sea exactamente lo que quiere para su cadena de entrada, pero podemos estar bastante seguros de que el primer modo de separación que describí no es correcto en absoluto. Por ejemplo, ¿qué pasaría si su cadena de entrada fuera 'Los Angeles, United States, North America'?

IFS=', ' read -ra a <<<'Los Angeles, United States, North America'; declare -p a;
## declare -a a=([0]="Los" [1]="Angeles" [2]="United" [3]="States" [4]="North" [5]="America")

2: Incluso si usara esta solución con un separador de un solo carácter (como una coma sola, es decir, sin espacios a continuación ni otras complicaciones), si el valor de la $stringvariable contiene algún LF, readentonces deje de procesar una vez que encuentre el primer LF. El readincorporado solo procesa una línea por invocación. Esto es cierto incluso si está canalizando o redirigiendo la entrada solo a la readdeclaración, como lo hacemos en este ejemplo con el mecanismo de cadena aquí y, por lo tanto, se garantiza que la entrada no procesada se perderá. El código que impulsa el readsistema integrado no tiene conocimiento del flujo de datos dentro de la estructura de comando que lo contiene.

Se podría argumentar que es poco probable que esto cause un problema, pero aún así, es un peligro sutil que debe evitarse si es posible. Se debe al hecho de que el readsistema integrado en realidad realiza dos niveles de división de la entrada: primero en líneas y luego en campos. Dado que el OP solo quiere un nivel de división, este uso del readincorporado no es apropiado y debemos evitarlo.

3: Un problema potencial no obvio con esta solución es que readsiempre elimina el campo final si está vacío, aunque en caso contrario conserva los campos vacíos. Aquí hay una demostración:

string=', , a, , b, c, , , '; IFS=', ' read -ra a <<<"$string"; declare -p a;
## declare -a a=([0]="" [1]="" [2]="a" [3]="" [4]="b" [5]="c" [6]="" [7]="")

Quizás al OP no le importe esto, pero sigue siendo una limitación que vale la pena conocer. Reduce la robustez y generalidad de la solución.

Este problema se puede resolver agregando un delimitador final ficticio a la cadena de entrada justo antes de alimentarla read, como lo demostraré más adelante.


Respuesta incorrecta #2

string="1:2:3:4:5"
set -f                     # avoid globbing (expansion of *).
array=(${string//:/ })

Idea parecida:

t="one,two,three"
a=($(echo $t | tr ',' "\n"))

(Nota: agregué los paréntesis que faltaban alrededor de la sustitución del comando que el respondedor parece haber omitido).

Idea parecida:

string="1,2,3,4"
array=(`echo $string | sed 's/,/\n/g'`)

Estas soluciones aprovechan la división de palabras en una asignación de matriz para dividir la cadena en campos. Curiosamente, al igual que read, la división general de palabras también utiliza la $IFSvariable especial, aunque en este caso se da a entender que está configurada en su valor predeterminado de <space><tab><newline> y, por lo tanto, cualquier secuencia de uno o más IFS. Los caracteres (que ahora son todos espacios en blanco) se consideran un delimitador de campo.

Esto resuelve el problema de los dos niveles de división cometidos por read, ya que la división de palabras por sí sola constituye solo un nivel de división. Pero al igual que antes, el problema aquí es que los campos individuales en la cadena de entrada ya pueden contener $IFScaracteres y, por lo tanto, se dividirían incorrectamente durante la operación de división de palabras. Este no es el caso para ninguna de las cadenas de entrada de muestra proporcionadas por estos respondedores (qué conveniente...), pero, por supuesto, eso no cambia el hecho de que cualquier base de código que usara este modismo correría el riesgo de explotar si esta suposición alguna vez fuera violada en algún momento. Una vez más, considere mi contraejemplo de 'Los Angeles, United States, North America'(o 'Los Angeles:United States:North America').

Además, la división de palabras normalmente va seguida de la expansión del nombre del archivo ( también conocida como expansión del nombre de la ruta, también conocida como globbing), que, si se hace, podría dañar potencialmente las palabras que contienen los caracteres *, ?, o [seguidos de ](y, si extglobestá configurado, fragmentos entre paréntesis precedidos por ?, *, +, @, o !) comparándolos con objetos del sistema de archivos y expandiendo las palabras ("globs") en consecuencia. El primero de estos tres respondedores ha solucionado inteligentemente este problema al ejecutar set -fde antemano para desactivar el globbing. Técnicamente, esto funciona (aunque probablemente deberías agregarlo set +fdespués para volver a habilitar el globbing para el código posterior que puede depender de ello), pero no es deseable tener que alterar la configuración global del shell para poder hackear una operación básica de análisis de cadena a matriz en código local. .

Otro problema con esta respuesta es que se perderán todos los campos vacíos. Esto puede ser un problema o no, dependiendo de la aplicación.

Nota: Si va a utilizar esta solución, es mejor utilizar la forma de expansión de parámetros${string//:/ } de "sustitución de patrón" , en lugar de tomarse la molestia de invocar una sustitución de comando (que bifurca el shell), iniciar una canalización y ejecutar un ejecutable externo ( o ), ya que la expansión de parámetros es puramente una operación interna del shell. (Además, para las soluciones y , la variable de entrada debe estar entre comillas dobles dentro de la sustitución del comando; de lo contrario, la división de palabras tendría efecto en el comando y potencialmente alteraría los valores de los campos. Además, la forma de sustitución del comando es preferible a la antigua formulario ya que simplifica el anidamiento de sustituciones de comandos y permite un mejor resaltado de sintaxis por parte de los editores de texto).trsedtrsedecho$(...)`...`


Respuesta incorrecta #3

str="a, b, c, d"  # assuming there is a space after ',' as in Q
arr=(${str//,/})  # delete all occurrences of ','

Esta respuesta es casi la misma que la número 2 . La diferencia es que el respondedor ha asumido que los campos están delimitados por dos caracteres, uno de los cuales está representado en el valor predeterminado $IFSy el otro no. Resolvió este caso bastante específico eliminando el carácter no representado por IFS usando una expansión de sustitución de patrones y luego usando la división de palabras para dividir los campos en el carácter delimitador superviviente representado por IFS.

Esta no es una solución muy genérica. Además, se puede argumentar que la coma es realmente el carácter delimitador "principal" aquí, y que eliminarla y luego depender del carácter de espacio para dividir el campo es simplemente incorrecto. Una vez más, considere mi contraejemplo: 'Los Angeles, United States, North America'.

Además, nuevamente, la expansión del nombre de archivo podría dañar las palabras expandidas, pero esto se puede evitar deshabilitando temporalmente el globbing para la tarea con set -fy luego set +f.

Además, nuevamente, se perderán todos los campos vacíos, lo que puede ser un problema o no dependiendo de la aplicación.


Respuesta incorrecta #4

string='first line
second line
third line'

oldIFS="$IFS"
IFS='
'
IFS=${IFS:0:1} # this is useful to format your code with tabs
lines=( $string )
IFS="$oldIFS"

Esto es similar a los números 2 y 3 en que utiliza la división de palabras para realizar el trabajo, solo que ahora el código se establece explícitamente $IFSpara contener solo el delimitador de campo de un solo carácter presente en la cadena de entrada. Debe repetirse que esto no puede funcionar para delimitadores de campos de varios caracteres, como el delimitador de espacio de coma del OP. Pero para un delimitador de un solo carácter como el LF usado en este ejemplo, en realidad casi es perfecto. Los campos no se pueden dividir involuntariamente por la mitad como vimos con respuestas incorrectas anteriores, y solo hay un nivel de división, según sea necesario.

Un problema es que la expansión del nombre de archivo corromperá las palabras afectadas como se describió anteriormente, aunque una vez más esto se puede resolver envolviendo la declaración crítica en set -fy set +f.

Otro problema potencial es que, dado que LF califica como un "carácter de espacio en blanco IFS" como se definió anteriormente, todos los campos vacíos se perderán, tal como en los puntos 2 y 3 . Por supuesto, esto no sería un problema si el delimitador resulta ser un "carácter de espacio en blanco IFS" y, dependiendo de la aplicación, puede que no importe de todos modos, pero vicia la generalidad de la solución.

Entonces, para resumir, suponiendo que tiene un delimitador de un carácter, y es un "carácter de espacio en blanco IFS" o no le importan los campos vacíos, y envuelve la declaración crítica en set -fy set +f, entonces esta solución funciona , pero por lo demás no.

(Además, a modo de información, asignar un LF a una variable en bash se puede hacer más fácilmente con la $'...'sintaxis, por ejemplo IFS=$'\n';).


Respuesta incorrecta #5

countries='Paris, France, Europe'
OIFS="$IFS"
IFS=', ' array=($countries)
IFS="$OIFS"

Idea parecida:

IFS=', ' eval 'array=($string)'

Esta solución es efectivamente un cruce entre la n.º 1 (en el sentido de que se establece $IFSen espacio de coma) y la n.º 2-4 (en el sentido de que utiliza la división de palabras para dividir la cadena en campos). Debido a esto, sufre la mayoría de los problemas que afectan a todas las respuestas incorrectas anteriores, algo así como el peor de todos los mundos.

Además, con respecto a la segunda variante, puede parecer que la evalllamada es completamente innecesaria, ya que su argumento es una cadena literal entre comillas simples y, por lo tanto, se conoce estáticamente. Pero en realidad hay un beneficio muy no obvio al usarlo evalde esta manera. Normalmente, cuando ejecuta un comando simple que consiste únicamente en una asignación de variable , es decir, sin una palabra de comando real a continuación, la asignación tiene efecto en el entorno del shell:

IFS=', '; ## changes $IFS in the shell environment

Esto es cierto incluso si el comando simple implica múltiples asignaciones de variables; Nuevamente, mientras no haya una palabra de comando, todas las asignaciones de variables afectan el entorno del shell:

IFS=', ' array=($countries); ## changes both $IFS and $array in the shell environment

Pero, si la asignación de variable está adjunta a un nombre de comando (me gusta llamar a esto "asignación de prefijo"), entonces no afecta el entorno del shell y, en cambio, solo afecta el entorno del comando ejecutado, independientemente de si es un comando incorporado. o externo:

IFS=', ' :; ## : is a builtin command, the $IFS assignment does not outlive it
IFS=', ' env; ## env is an external command, the $IFS assignment does not outlive it

Cita relevante del manual de bash :

Si no se obtiene ningún nombre de comando, las asignaciones de variables afectan el entorno de shell actual. De lo contrario, las variables se agregan al entorno del comando ejecutado y no afectan el entorno actual del shell.

Es posible explotar esta característica de asignación de variables para cambiar $IFSsólo temporalmente, lo que nos permite evitar toda la táctica de guardar y restaurar como la que se hace con la $OIFSvariable en la primera variante. Pero el desafío que enfrentamos aquí es que el comando que necesitamos ejecutar es en sí mismo una mera asignación de variable y, por lo tanto, no implicaría una palabra de comando para hacer que la $IFSasignación sea temporal. Podría pensar, bueno, ¿por qué no simplemente agregar una palabra de comando no operativa a la declaración como para : builtinhacer que la $IFSasignación sea temporal? $arrayEsto no funciona porque la asignación también sería temporal:

IFS=', ' array=($countries) :; ## fails; new $array value never escapes the : command

Así que estamos efectivamente en un callejón sin salida, en una especie de callejón sin salida. Pero, cuando evalejecuta su código, lo ejecuta en el entorno shell, como si fuera código fuente estático normal, y por lo tanto podemos ejecutar la $arrayasignación dentro del evalargumento para que tenga efecto en el entorno shell, mientras que la $IFSasignación de prefijo que tiene el prefijo del evalcomando no sobrevivirá al evalcomando. Este es exactamente el truco que se utiliza en la segunda variante de esta solución:

IFS=', ' eval 'array=($string)'; ## $IFS does not outlive the eval command, but $array does

So, as you can see, it's actually quite a clever trick, and accomplishes exactly what is required (at least with respect to assignment effectation) in a rather non-obvious way. I'm actually not against this trick in general, despite the involvement of eval; just be careful to single-quote the argument string to guard against security threats.

But again, because of the "worst of all worlds" agglomeration of problems, this is still a wrong answer to the OP's requirement.


Wrong answer #6

IFS=', '; array=(Paris, France, Europe)

IFS=' ';declare -a array=(Paris France Europe)

Um... what? The OP has a string variable that needs to be parsed into an array. This "answer" starts with the verbatim contents of the input string pasted into an array literal. I guess that's one way to do it.

It looks like the answerer may have assumed that the $IFS variable affects all bash parsing in all contexts, which is not true. From the bash manual:

IFS    The Internal Field Separator that is used for word splitting after expansion and to split lines into words with the read builtin command. The default value is <space><tab><newline>.

So the $IFS special variable is actually only used in two contexts: (1) word splitting that is performed after expansion (meaning not when parsing bash source code) and (2) for splitting input lines into words by the read builtin.

Let me try to make this clearer. I think it might be good to draw a distinction between parsing and execution. Bash must first parse the source code, which obviously is a parsing event, and then later it executes the code, which is when expansion comes into the picture. Expansion is really an execution event. Furthermore, I take issue with the description of the $IFS variable that I just quoted above; rather than saying that word splitting is performed after expansion, I would say that word splitting is performed during expansion, or, perhaps even more precisely, word splitting is part of the expansion process. The phrase "word splitting" refers only to this step of expansion; it should never be used to refer to the parsing of bash source code, although unfortunately the docs do seem to throw around the words "split" and "words" a lot. Here's a relevant excerpt from the linux.die.net version of the bash manual:

Expansion is performed on the command line after it has been split into words. There are seven kinds of expansion performed: brace expansion, tilde expansion, parameter and variable expansion, command substitution, arithmetic expansion, word splitting, and pathname expansion.

The order of expansions is: brace expansion; tilde expansion, parameter and variable expansion, arithmetic expansion, and command substitution (done in a left-to-right fashion); word splitting; and pathname expansion.

You could argue the GNU version of the manual does slightly better, since it opts for the word "tokens" instead of "words" in the first sentence of the Expansion section:

Expansion is performed on the command line after it has been split into tokens.

The important point is, $IFS does not change the way bash parses source code. Parsing of bash source code is actually a very complex process that involves recognition of the various elements of shell grammar, such as command sequences, command lists, pipelines, parameter expansions, arithmetic substitutions, and command substitutions. For the most part, the bash parsing process cannot be altered by user-level actions like variable assignments (actually, there are some minor exceptions to this rule; for example, see the various compatxx shell settings, which can change certain aspects of parsing behavior on-the-fly). The upstream "words"/"tokens" that result from this complex parsing process are then expanded according to the general process of "expansion" as broken down in the above documentation excerpts, where word splitting of the expanded (expanding?) text into downstream words is simply one step of that process. Word splitting only touches text that has been spit out of a preceding expansion step; it does not affect literal text that was parsed right off the source bytestream.


Wrong answer #7

string='first line
        second line
        third line'

while read -r line; do lines+=("$line"); done <<<"$string"

This is one of the best solutions. Notice that we're back to using read. Didn't I say earlier that read is inappropriate because it performs two levels of splitting, when we only need one? The trick here is that you can call read in such a way that it effectively only does one level of splitting, specifically by splitting off only one field per invocation, which necessitates the cost of having to call it repeatedly in a loop. It's a bit of a sleight of hand, but it works.

But there are problems. First: When you provide at least one NAME argument to read, it automatically ignores leading and trailing whitespace in each field that is split off from the input string. This occurs whether $IFS is set to its default value or not, as described earlier in this post. Now, the OP may not care about this for his specific use-case, and in fact, it may be a desirable feature of the parsing behavior. But not everyone who wants to parse a string into fields will want this. There is a solution, however: A somewhat non-obvious usage of read is to pass zero NAME arguments. In this case, read will store the entire input line that it gets from the input stream in a variable named $REPLY, and, as a bonus, it does not strip leading and trailing whitespace from the value. This is a very robust usage of read which I've exploited frequently in my shell programming career. Here's a demonstration of the difference in behavior:

string=$'  a  b  \n  c  d  \n  e  f  '; ## input string

a=(); while read -r line; do a+=("$line"); done <<<"$string"; declare -p a;
## declare -a a=([0]="a  b" [1]="c  d" [2]="e  f") ## read trimmed surrounding whitespace

a=(); while read -r; do a+=("$REPLY"); done <<<"$string"; declare -p a;
## declare -a a=([0]="  a  b  " [1]="  c  d  " [2]="  e  f  ") ## no trimming

The second issue with this solution is that it does not actually address the case of a custom field separator, such as the OP's comma-space. As before, multicharacter separators are not supported, which is an unfortunate limitation of this solution. We could try to at least split on comma by specifying the separator to the -d option, but look what happens:

string='Paris, France, Europe';
a=(); while read -rd,; do a+=("$REPLY"); done <<<"$string"; declare -p a;
## declare -a a=([0]="Paris" [1]=" France")

Predictably, the unaccounted surrounding whitespace got pulled into the field values, and hence this would have to be corrected subsequently through trimming operations (this could also be done directly in the while-loop). But there's another obvious error: Europe is missing! What happened to it? The answer is that read returns a failing return code if it hits end-of-file (in this case we can call it end-of-string) without encountering a final field terminator on the final field. This causes the while-loop to break prematurely and we lose the final field.

Technically this same error afflicted the previous examples as well; the difference there is that the field separator was taken to be LF, which is the default when you don't specify the -d option, and the <<< ("here-string") mechanism automatically appends a LF to the string just before it feeds it as input to the command. Hence, in those cases, we sort of accidentally solved the problem of a dropped final field by unwittingly appending an additional dummy terminator to the input. Let's call this solution the "dummy-terminator" solution. We can apply the dummy-terminator solution manually for any custom delimiter by concatenating it against the input string ourselves when instantiating it in the here-string:

a=(); while read -rd,; do a+=("$REPLY"); done <<<"$string,"; declare -p a;
declare -a a=([0]="Paris" [1]=" France" [2]=" Europe")

There, problem solved. Another solution is to only break the while-loop if both (1) read returned failure and (2) $REPLY is empty, meaning read was not able to read any characters prior to hitting end-of-file. Demo:

a=(); while read -rd,|| [[ -n "$REPLY" ]]; do a+=("$REPLY"); done <<<"$string"; declare -p a;
## declare -a a=([0]="Paris" [1]=" France" [2]=$' Europe\n')

This approach also reveals the secretive LF that automatically gets appended to the here-string by the <<< redirection operator. It could of course be stripped off separately through an explicit trimming operation as described a moment ago, but obviously the manual dummy-terminator approach solves it directly, so we could just go with that. The manual dummy-terminator solution is actually quite convenient in that it solves both of these two problems (the dropped-final-field problem and the appended-LF problem) in one go.

So, overall, this is quite a powerful solution. It's only remaining weakness is a lack of support for multicharacter delimiters, which I will address later.


Wrong answer #8

string='first line
        second line
        third line'

readarray -t lines <<<"$string"

(This is actually from the same post as #7; the answerer provided two solutions in the same post.)

The readarray builtin, which is a synonym for mapfile, is ideal. It's a builtin command which parses a bytestream into an array variable in one shot; no messing with loops, conditionals, substitutions, or anything else. And it doesn't surreptitiously strip any whitespace from the input string. And (if -O is not given) it conveniently clears the target array before assigning to it. But it's still not perfect, hence my criticism of it as a "wrong answer".

First, just to get this out of the way, note that, just like the behavior of read when doing field-parsing, readarray drops the trailing field if it is empty. Again, this is probably not a concern for the OP, but it could be for some use-cases. I'll come back to this in a moment.

Second, as before, it does not support multicharacter delimiters. I'll give a fix for this in a moment as well.

Third, the solution as written does not parse the OP's input string, and in fact, it cannot be used as-is to parse it. I'll expand on this momentarily as well.

For the above reasons, I still consider this to be a "wrong answer" to the OP's question. Below I'll give what I consider to be the right answer.


Right answer

Here's a naïve attempt to make #8 work by just specifying the -d option:

string='Paris, France, Europe';
readarray -td, a <<<"$string"; declare -p a;
## declare -a a=([0]="Paris" [1]=" France" [2]=$' Europe\n')

We see the result is identical to the result we got from the double-conditional approach of the looping read solution discussed in #7. We can almost solve this with the manual dummy-terminator trick:

readarray -td, a <<<"$string,"; declare -p a;
## declare -a a=([0]="Paris" [1]=" France" [2]=" Europe" [3]=$'\n')

The problem here is that readarray preserved the trailing field, since the <<< redirection operator appended the LF to the input string, and therefore the trailing field was not empty (otherwise it would've been dropped). We can take care of this by explicitly unsetting the final array element after-the-fact:

readarray -td, a <<<"$string,"; unset 'a[-1]'; declare -p a;
## declare -a a=([0]="Paris" [1]=" France" [2]=" Europe")

The only two problems that remain, which are actually related, are (1) the extraneous whitespace that needs to be trimmed, and (2) the lack of support for multicharacter delimiters.

The whitespace could of course be trimmed afterward (for example, see How to trim whitespace from a Bash variable?). But if we can hack a multicharacter delimiter, then that would solve both problems in one shot.

Unfortunately, there's no direct way to get a multicharacter delimiter to work. The best solution I've thought of is to preprocess the input string to replace the multicharacter delimiter with a single-character delimiter that will be guaranteed not to collide with the contents of the input string. The only character that has this guarantee is the NUL byte. This is because, in bash (though not in zsh, incidentally), variables cannot contain the NUL byte. This preprocessing step can be done inline in a process substitution. Here's how to do it using awk:

readarray -td '' a < <(awk '{ gsub(/, /,"\0"); print; }' <<<"$string, "); unset 'a[-1]';
declare -p a;
## declare -a a=([0]="Paris" [1]="France" [2]="Europe")

There, finally! This solution will not erroneously split fields in the middle, will not cut out prematurely, will not drop empty fields, will not corrupt itself on filename expansions, will not automatically strip leading and trailing whitespace, will not leave a stowaway LF on the end, does not require loops, and does not settle for a single-character delimiter.


Trimming solution

Lastly, I wanted to demonstrate my own fairly intricate trimming solution using the obscure -C callback option of readarray. Unfortunately, I've run out of room against Stack Overflow's draconian 30,000 character post limit, so I won't be able to explain it. I'll leave that as an exercise for the reader.

function mfcb { local val="$4"; "$1"; eval "$2[$3]=\$val;"; };
function val_ltrim { if [[ "$val" =~ ^[[:space:]]+ ]]; then val="${val:${#BASH_REMATCH[0]}}"; fi; };
function val_rtrim { if [[ "$val" =~ [[:space:]]+$ ]]; then val="${val:0:${#val}-${#BASH_REMATCH[0]}}"; fi; };
function val_trim { val_ltrim; val_rtrim; };
readarray -c1 -C 'mfcb val_trim a' -td, <<<"$string,"; unset 'a[-1]'; declare -p a;
## declare -a a=([0]="Paris" [1]="France" [2]="Europe")
bgoldst avatar Jul 19 '2017 21:07 bgoldst