L'IFS (séparateur de champ interne) peut-il fonctionner comme un seul séparateur pour plusieurs caractères de délimiteur consécutifs?

10

L'analyse d'un tableau à l'aide d'IFS avec des valeurs d'espace non blanches crée des éléments vides.
Même utiliser tr -spour réduire plusieurs délimitations à une seule délimitation n'est pas suffisant.
Un exemple peut expliquer le problème plus clairement.
Existe - t-il un moyen d'obtenir des résultats "normaux" via une modification d'IFS (y a-t-il un paramètre associé pour changer le comportement d'IFS? .... ie. Pour agir de la même manière que l'espace blanc par défaut IFS.

var=" abc  def   ghi    "
echo "============== IFS=<default>"
arr=($var)
for x in ${!arr[*]} ; do
   echo "# arr[$x] \"${arr[x]}\""
done
#
sfi="$IFS" ; IFS=':'
set -f # Disable file name generation (globbing)
       # (This  data won't "glob", but unless globbing     
       #  is actually needed, turn if off, because   
       #  unusual/unexpected combinations of data can glob!
       #  and they can do it in the most obscure ways...  
       #  With IFS, "you're not in Kansas any more! :)  
var=":abc::def:::ghi::::"
echo "============== IFS=$IFS"
arr=($var)
for x in ${!arr[*]} ; do
   echo "# arr[$x] \"${arr[x]}\""
done
echo "============== IFS=$IFS and tr"
arr=($(echo -n "$var"|tr -s "$IFS"))
for x in ${!arr[*]} ; do
   echo "# arr[$x] \"${arr[x]}\""
done
set +f     # enable globbing 
IFS="$sfi" # re-instate original IFS val
echo "============== IFS=<default>"

Voici la sortie


============== IFS=<default>
# arr[0] "abc"
# arr[1] "def"
# arr[2] "ghi"
============== IFS=:
# arr[0] ""
# arr[1] "abc"
# arr[2] ""
# arr[3] "def"
# arr[4] ""
# arr[5] ""
# arr[6] "ghi"
# arr[7] ""
# arr[8] ""
# arr[9] ""
============== IFS=: and tr
# arr[0] ""
# arr[1] "abc"
# arr[2] "def"
# arr[3] "ghi"
============== IFS=<default>
Peter.O
la source
Il y a une meilleure réponse (je pense) à la même question: stackoverflow.com/a/14789518/1765658
F. Hauri

Réponses:

3

Pour supprimer plusieurs caractères de délimiteur consécutifs (sans espace), deux extensions de paramètres (chaîne / tableau) peuvent être utilisées. L'astuce consiste à définir la IFSvariable sur la chaîne vide pour l'expansion des paramètres du tableau.

Ceci est documenté dans man bashsous Word Splitting :

Les arguments nuls implicites non cotés, résultant de l'expansion de paramètres sans valeur, sont supprimés.

(
set -f
str=':abc::def:::ghi::::'
IFS=':'
arr=(${str})
IFS=""
arr=(${arr[@]})

echo ${!arr[*]}

for ((i=0; i < ${#arr[@]}; i++)); do 
   echo "${i}: '${arr[${i}]}'"
done
)
nazad
la source
Bien! Une méthode simple et efficace - sans avoir besoin d'une boucle bash et sans avoir besoin d'appeler une application utilitaire - BTW. Comme vous l'avez mentionné "(non-espace)" , je précise, pour plus de clarté, que cela fonctionne bien avec n'importe quelle combinaison de caractères de délimitation, y compris l'espace.
Peter.O
Dans mes tests, le paramètre IFS=' '(c'est-à-dire un espace) se comporte de la même manière. Je trouve cela moins déroutant qu'un argument nul explicite ("" ou '') de IFS.
Micha Wiedenmann du
C'est une sorte de solution terrible si vos données contiennent des espaces blancs intégrés. Ceci, si vos données étaient 'a bc' au lieu de 'abc', IFS = "" diviserait 'a' en un élément distinct de 'bc'.
Dejay Clayton du
5

Depuis la bashpage de manuel:

Tout caractère dans IFS qui n'est pas un espace IFS, ainsi que tout caractère d'espace IFS adjacent, délimite un champ. Une séquence de caractères blancs IFS est également traitée comme un délimiteur.

Cela signifie que les espaces blancs IFS (espace, tabulation et nouvelle ligne) ne sont pas traités comme les autres séparateurs. Si vous souhaitez obtenir exactement le même comportement avec un séparateur alternatif, vous pouvez effectuer un échange de séparateur à l'aide de trou sed:

var=":abc::def:::ghi::::"
arr=($(echo -n $var | sed 's/ /%#%#%#%#%/g;s/:/ /g'))
for x in ${!arr[*]} ; do
   el=$(echo -n $arr | sed 's/%#%#%#%#%/ /g')
   echo "# arr[$x] \"$el\""
done

La %#%#%#%#%chose est une valeur magique pour remplacer les espaces possibles à l'intérieur des champs, elle devrait être "unique" (ou très peu liée). Si vous êtes sûr qu'aucun espace ne sera jamais dans les champs, laissez tomber cette partie).

jon_d
la source
@FussyS ... Merci (voir la modification dans ma question) ... Vous m'avez peut-être donné la réponse à ma question prévue .. et cette réponse peut être (probablement) "Il n'y a aucun moyen de faire en sorte qu'IFS se comporte dans le manière que je veux "... J'ai l'intentionet les trexemples pour montrer le problème ... Je veux éviter un appel système, donc je vais regarder une option bash au-delà de celle ${var##:}que j'ai mentionnée dans mon commentaire à la réponse de glen .... Je vais attendre un moment .. peut-être qu'il y a un moyen d'amadouer IFS, sinon la première partie de votre réponse est après ...
Peter.O
Ce traitement IFSest le même dans tous les shells de style Bourne, il est spécifié dans POSIX .
Gilles 'SO- arrête d'être méchant'
Plus de 4 ans depuis que j'ai posé cette question - j'ai trouvé que la réponse de @ nazad (publiée il y a plus d'un an) était le moyen le plus simple de jongler avec IFS pour créer un tableau avec n'importe quel nombre et combinaison de IFScaractères comme chaîne de délimitation. Ma question a été mieux répondue par jon_d, mais la réponse de @ nazad montre une manière astucieuse à utiliser IFSsans boucles et sans applications utilitaires.
Peter.O
2

Étant donné que bash IFS ne fournit pas de méthode interne pour traiter les caractères de délimiteur consécutifs comme un seul délimiteur (pour les délimiteurs non blancs), j'ai mis au point une version tout bash (par opposition à l'utilisation d'un appel externe, par exemple tr, awk, sed )

Il peut gérer les IFS multi-caractères.

Voici ses résultats au moment de l'exécution, ainsi que des tests similaires pour les options tret awkaffichées sur cette page Q / A ... Les tests sont basés sur 10000 itérations de construction du tableau (sans E / S) ...

pure bash     3.174s (28 char IFS)
call (awk) 0m32.210s  (1 char IFS) 
call (tr)  0m32.178s  (1 char IFS) 

Voici la sortie

# dlm_str  = :.~!@#$%^&()_+-=`}{][ ";></,
# original = :abc:.. def:.~!@#$%^&()_+-=`}{][ ";></,'single*quote?'..123:
# unified  = :abc::::def::::::::::::::::::::::::::::'single*quote?'::123:
# max-w 2^ = ::::::::::::::::
# shrunk.. = :abc:def:'single*quote?':123:
# arr[0] "abc"
# arr[1] "def"
# arr[2] "'single*quote?'"
# arr[3] "123"

Voici le script

#!/bin/bash

# Note: This script modifies the source string. 
#       so work with a copy, if you need the original. 
# also: Use the name varG (Global) it's required by 'shrink_repeat_chars'
#
# NOTE: * asterisk      in IFS causes a regex(?) issue,     but  *  is ok in data. 
# NOTE: ? Question-mark in IFS causes a regex(?) issue,     but  ?  is ok in data. 
# NOTE: 0..9 digits     in IFS causes empty/wacky elements, but they're ok in data.
# NOTE: ' single quote  in IFS; don't know yet,             but  '  is ok in data.
# 
function shrink_repeat_chars () # A 'tr -s' analog
{
  # Shrink repeating occurrences of char
  #
  # $1: A string of delimiters which when consecutively repeated and are       
  #     considered as a shrinkable group. A example is: "   " whitespace delimiter.
  #
  # $varG  A global var which contains the string to be "shrunk".
  #
# echo "# dlm_str  = $1" 
# echo "# original = $varG" 
  dlms="$1"        # arg delimiter string
  dlm1=${dlms:0:1} # 1st delimiter char  
  dlmw=$dlm1       # work delimiter  
  # More than one delimiter char
  # ============================
  # When a delimiter contains more than one char.. ie (different byte` values),    
  # make all delimiter-chars in string $varG the same as the 1st delimiter char.
  ix=1;xx=${#dlms}; 
  while ((ix<xx)) ; do # Where more than one delim char, make all the same in varG  
    varG="${varG//${dlms:$ix:1}/$dlm1}"
    ix=$((ix+1))
  done
# echo "# unified  = $varG" 
  #
  # Binary shrink
  # =============
  # Find the longest required "power of 2' group needed for a binary shrink
  while [[ "$varG" =~ .*$dlmw$dlmw.* ]] ; do dlmw=$dlmw$dlmw; done # double its length
# echo "# max-w 2^ = $dlmw"
  #
  # Shrik groups of delims to a single char
  while [[ ! "$dlmw" == "$dlm1" ]] ; do
    varG=${varG//${dlmw}$dlm1/$dlm1}
    dlmw=${dlmw:$((${#dlmw}/2))}
  done
  varG=${varG//${dlmw}$dlm1/$dlm1}
# echo "# shrunk.. = $varG"
}

# Main
  varG=':abc:.. def:.~!@#$%^&()_+-=`}{][ ";></,'\''single*quote?'\''..123:' 
  sfi="$IFS"; IFS=':.~!@#$%^&()_+-=`}{][ ";></,' # save original IFS and set new multi-char IFS
  set -f                                         # disable globbing
  shrink_repeat_chars "$IFS" # The source string name must be $varG
  arr=(${varG:1})    # Strip leading dlim;  A single trailing dlim is ok (strangely
  for ix in ${!arr[*]} ; do  # Dump the array
     echo "# arr[$ix] \"${arr[ix]}\""
  done
  set +f     # re-enable globbing   
  IFS="$sfi" # re-instate the original IFS
  #
exit
Peter.O
la source
Excellent travail, intéressant +1!
F.Hauri
1

Vous pouvez aussi le faire avec gawk, mais ce n'est pas joli:

var=":abc::def:::ghi::::"
out=$( gawk -F ':+' '
  {
    # strip delimiters from the ends of the line
    sub("^"FS,"")
    sub(FS"$","")
    # then output in a bash-friendly format
    for (i=1;i<=NF;i++) printf("\"%s\" ", $i)
    print ""
  }
' <<< "$var" )
eval arr=($out)
for x in ${!arr[*]} ; do
  echo "# arr[$x] \"${arr[x]}\""
done

les sorties

# arr[0] "abc"
# arr[1] "def"
# arr[2] "ghi"
glenn jackman
la source
Merci ... Il semble que je n'ai pas été clair dans ma demande principale (question modifiée) ... C'est assez facile de le faire en changeant simplement mon $varen ${var##:}... Je cherchais vraiment un moyen de modifier IFS lui-même .. Je veux pour le faire sans appel externe (j'ai le sentiment que bash peut le faire plus efficacement que n'importe quel externe .. alors je vais continuer sur cette voie) ... votre méthode fonctionne (+1) .... Pour autant que comme la modification de l'entrée va, je préfère l'essayer avec bash, plutôt qu'avec awk ou tr (cela éviterait un appel système), mais je suis vraiment en train de traîner pour un tweak IFS ...
Peter.O
@fred, comme mentionné, IFS ne prend que plusieurs délimètres consécutifs pour la valeur d'espace par défaut. Sinon, des délimiteurs consécutifs entraînent des champs vides superflus. Je m'attends à ce qu'il soit extrêmement peu probable qu'un ou deux appels externes aient un impact réel sur les performances.
glenn jackman
@glen .. (Vous avez dit que votre réponse n'est pas "jolie" .. Je pense que oui! :) Cependant, j'ai mis au point une version tout bash (par rapport à un appel externe) et basée sur 10000 itérations de construction du tableau ( pas d'E / S) ... bash 1.276s... call (awk) 0m32.210s,,, call (tr) 0m32.178s... Faites cela plusieurs fois et vous pourriez penser que bash est lent! ... est awk plus facile dans ce cas? ... pas si vous avez déjà l'extrait :) ... Je le posterai plus tard; je dois y aller maintenant.
Peter.O
Soit dit en passant, votre script gawk ... Je n'ai fondamentalement jamais utilisé awk auparavant, donc je l'ai regardé (et d'autres) en détail ... Je ne peux pas choisir pourquoi, mais je mentionnerai la question de toute façon .. Administré données citées, il desserre les citations, et se divise en espaces entre les guillemets .. et tombe en panne pour un nombre impair de citations ... Voici les données de test:var="The \"X\" factor:::A single '\"' crashes:::\"One Two\""
Peter.O
-1

La réponse simple est: réduire tous les délimiteurs à un (le premier).
Cela nécessite une boucle (qui s'exécute moins de log(N)fois):

 var=':a bc::d ef:#$%_+$$%      ^%&*(*&*^
 $#,.::ghi::*::'                           # a long test string.
 d=':@!#$%^&*()_+,.'                       # delimiter set
 f=${d:0:1}                                # first delimiter
 v=${var//["$d"]/"$f"};                    # convert all delimiters to
 :                                         # the first of the delimiter set.
 tmp=$v                                    # temporal variable (v).
 while
     tmp=${tmp//["$f"]["$f"]/"$f"};        # collapse each two delimiters to one
     [[ "$tmp" != "$v" ]];                 # If there was a change
 do
     v=$tmp;                               # actualize the value of the string.
 done

Il ne reste plus qu'à diviser correctement la chaîne sur un délimiteur et à l'imprimer:

 readarray -td "$f" arr < <(printf '%s%s' "$v"'' "$f")
 printf '<%s>' "${arr[@]}" ; echo

Pas besoin set -fni de changer IFS.
Testé avec des espaces, des nouvelles lignes et des caractères globaux. Tout le travail. Assez lent (comme devrait l'être une boucle shell).
Mais uniquement pour bash (bash 4.4+ en raison de l'option -dde readarray).


sh

Une version shell ne peut pas utiliser un tableau, le seul tableau disponible sont les paramètres positionnels.
L'utilisation tr -sn'est qu'une ligne (IFS ne change pas dans le script):

 set -f; IFS=$f command eval set -- '$(echo "$var" | tr -s "$d" "[$f*]" )""'

Et imprimez-le:

 printf '<%s>' "$@" ; echo

Toujours lent, mais pas beaucoup plus.

La commande commandn'est pas valide dans Bourne.
Dans zsh, commandappelle uniquement les commandes externes et fait échouer eval si commandest utilisé.
Dans ksh, même avec command, la valeur d'IFS est modifiée dans la portée globale.
Et commandfait échouer le fractionnement dans les shells liés à mksh (mksh, lksh, posh) La suppression de la commande commandfait exécuter le code sur plus de shells. Mais: la suppression commandfera que IFS conservera sa valeur dans la plupart des shells (eval est une fonction spéciale) sauf en bash (sans mode posix) et zsh en mode par défaut (pas d'émulation). Ce concept ne peut pas fonctionner avec ou sans zsh par défaut command.


IFS à plusieurs caractères

Oui, IFS peut être multi-caractères, mais chaque caractère génère un argument:

 set -f; IFS="$d" command eval set -- '$(echo "$var" )""'
 printf '<%s>' "$@" ; echo

Sortira:

 <><a bc><><d ef><><><><><><><><><      ><><><><><><><><><
 ><><><><><><ghi><><><><><>

Avec bash, vous pouvez omettre le commandmot s'il n'est pas dans l'émulation sh / POSIX. La commande échouera dans ksh93 (IFS conserve la valeur modifiée). Dans zsh, la commande commandoblige zsh à rechercher evalune commande externe (qu'elle ne trouve pas) et échoue.

Ce qui se passe, c'est que les seuls caractères IFS qui sont automatiquement réduits à un délimiteur sont les espaces blancs IFS.
Un espace dans IFS réduira tous les espaces consécutifs en un seul. Un onglet réduira tous les onglets. Un espace et un onglet réduiront les séries d'espaces et / ou d'onglets dans un délimiteur. Répétez l'idée avec la nouvelle ligne.

Pour réduire plusieurs délimiteurs, une jonglerie est nécessaire.
En supposant que l'ASCII 3 (0x03) n'est pas utilisé dans l'entrée var:

 var=${var// /$'\3'}                       # protect spaces
 var=${var//["$d"]/ }                      # convert all delimiters to spaces
 set -f;                                   # avoid expanding globs.
 IFS=" " command eval set -- '""$var""'    # split on spaces.
 set -- "${@//$'\3'/ }"                    # convert spaces back.

La plupart des commentaires sur ksh, zsh et bash (about commandet IFS) s'appliquent toujours ici.

Une valeur de $'\0'serait moins probable dans la saisie de texte, mais les variables bash ne peuvent pas contenir de NUL ( 0x00).

Il n'y a pas de commandes internes dans sh pour effectuer les mêmes opérations de chaîne, donc tr est la seule solution pour les scripts sh.

Isaac
la source
Oui, j'ai écrit que pour le shell, le PO demandait: Bash. Dans ce shell, IFS n'est pas conservé. Et oui, n'est pas portable, pour zsh, par exemple. @ StéphaneChazelas
Isaac
Dans le cas de bash et zsh, ils se comportent comme POSIX le spécifie lorsqu'ils sont invoqués en tant que sh
Stéphane Chazelas
@ StéphaneChazelas Ajout de (nombreuses) notes sur les limitations de chaque shell.
Isaac
@ StéphaneChazelas Pourquoi le downvote?
Isaac
Je ne sais pas, ce n'était pas moi. BTW, je pense qu'il y a un Q&A dédié ici sur l' command evalIIRC par Gilles
Stéphane Chazelas