33

I have 10 text files and I want to paste each file with its pair, such that I have 5 total files.

I tried the following:

for i in 4_1 5_1 6_1 7_1 8_1
do
for j in 4_2 5_2 6_2 7_2 8_2
do
paste ${i}.txt ${j}.txt > ${i}.${j}.txt
done
done

However, this code combines every possible combination instead of just combining the matching pairs.

So I would like file 4_1.txt to be paired with 4_2.txt, 5_1.txt with 5_2.txt, etc.

thor
  • 21,418
  • 31
  • 87
  • 173
Rish
  • 794
  • 1
  • 6
  • 14
  • You need to loop over prefixes and suffixes not full file names. – Etan Reisner Feb 25 '15 at 17:14
  • The behavior you're getting is the same as what a nested `for` loop would do in *any* language; there's nothing bash-specific about it. – Charles Duffy Feb 25 '15 at 17:19
  • 1
    Every time I want to do something relatively simple like iterating over pairs of values, Bash makes the workload look like a insurmountable epic. So I don't use bash when needing anything more complex than a list of shell calls. – ThorSummoner Sep 29 '15 at 23:42
  • The original question is not necessarily about arrays. It's only about pairs. (Same is true for some of the answers here). – thor Oct 15 '21 at 20:18

6 Answers6

39

I agree with the answer currently proposed by fedorqui in the context of the question currently asked. The below is given only to provide some more general answers.

One more general approach (for bash 4.0 or newer) is to store your pairs in an associative array:

declare -A pairs=( [4_1]=4_2 [5_1]=5_2 [6_1]=6_2 [7_1]=7_2 [8_1]=8_2 )
for i in "${!pairs[@]}"; do
  j=${pairs[$i]}
  paste "$i.txt" "$j.txt" >"${i}.${j}.txt"
done

Another (compatible with older releases of bash) is to use more than one conventional array:

is=( 4_1 5_1 6_1 7_1 8_1 )
js=( 4_2 5_2 6_2 7_2 8_2 )
for idx in "${!is[@]}"; do
  i=${is[$idx]}
  j=${js[$idx]}
  paste "$i.txt" "$j.txt" >"$i.$j.txt"
done
Charles Duffy
  • 280,126
  • 43
  • 390
  • 441
  • I am disappointed that there is no leaner version of that something like looping over a list and then splitting each element into two parts – Fibo Kowalsky Apr 22 '20 at 13:56
  • This question is specified with two different lists provided. It's easy to do the other thing; it's just not what the OP here asked for. – Charles Duffy Apr 22 '20 at 14:01
  • 4
    @CharlesDuffy I'm here because I typed "[iterate in pairs bash](https://www.google.com/search?q=iterate+in+pairs+bash)" into Google, not because I care about OP's problem. – Boris Verkhovskiy Apr 06 '21 at 04:20
  • @Boris, ...which is evidence that this question should be edited to have a title that disambiguates from the other, related question -- if there are two distinct problems that can be described with the same words, they should be titled in a way to make it clear which is which. – Charles Duffy Apr 06 '21 at 15:00
  • @CharlesDuffy , could you point to a reference for handling pairs, or better yet, an array of arrays ? – Samuel Åslund Jan 25 '23 at 14:10
  • @SamuelÅslund, you mean a list of pairs, so if you _only_ have `is=( 4_a 5_b 6_c )`? In that case the usual string-splitting mechanisms, either with parameter expansion or `read -a` apply. But for a _true_ array of arrays you're talking about something like namerefs, since bash's arrays can only have strings as their values; the trick, then, is to make each such string _the name of the separate variable that stores the inner array_. Which is to say -- details matter in selecting an appropriate duplicate. – Charles Duffy Jan 25 '23 at 14:23
29

Simplest so far:

for i in "1 a" "2 b" "3 c"; do a=( $i ); echo "${a[1]}"; echo "${a[0]}"; done

a
1
b
2
c
3
tripleee
  • 175,061
  • 34
  • 275
  • 318
Fibo Kowalsky
  • 1,198
  • 12
  • 23
  • 2
    Why was this downvoted? It works on bash ver.3 and above, and simple enough to not require the use of heredoc. – syockit Jun 11 '20 at 02:21
  • 2
    a much better solution than the others – reflog Mar 30 '21 at 08:20
  • 2
    Simple and works well, thank you. – BB1 Apr 26 '21 at 19:10
  • Not my downvote, but why would you not put the values in an array right at the start then? – tripleee May 12 '22 at 11:00
  • 1
    How would this work if you wanted to save the array of pairs in a variable beforehand? Eg.: `DATA=("1 a" "2 b" "3 c"); for i in ${DATA[@]}; do echo $i; done` Because this iterates over each item seperately: 1, a, 2, b, 3, c ... so 6 times instead of the desired 3 times. (desired output would be: 1 a, 2 b, 3 c) – atripes Jan 17 '23 at 08:39
  • 1
    I found the answer in another post. The solution is to wrap the array in quotes when iterating: `for i in "${DATA[@]}" ... `. That results in the desired 3 loops over "1 a", "2 b", "3 c". – atripes Jan 17 '23 at 11:05
13

You can use an associative array:

animals=(dog cat mouse)
declare -A size=(
  [dog]=big
  [cat]=medium
  [mouse]=small
)
declare -A sound=(
  [dog]=barks
  [cat]=purrs
  [mouse]=cheeps
)
for animal in "${animals[@]}"; do
  echo "$animal is ${size[$animal]} and it ${sound[$animal]}"
done

This allows you traversing pairs, triples, etc. Credits: the original idea is taken from @CharlesDuffy-s answer.

VasiliNovikov
  • 9,681
  • 4
  • 44
  • 62
  • Didn't I suggest (and demonstrate) this technique -- in context appropriate to the OP's question, revolving around invocation of `paste` -- [back in 2015](https://stackoverflow.com/a/28725562/14122)? – Charles Duffy Nov 16 '18 at 23:50
  • Valid question. Originally I started as a somewhat different answer, with your answer in "credits". But then in September I some why decided to refactor everything and practically get rid of some of the stuff. I've restored the original answer now. – VasiliNovikov Nov 17 '18 at 09:12
11

If you want to use one variable and perform and action with it, you just need to use one loop:

for file in 4 5 6 7 8
do
   paste "${file}_1" "${file}_2"
done

This will do

paste 4_1 4_2
paste 5_1 5_2
...
fedorqui
  • 275,237
  • 103
  • 548
  • 598
6

the above did not work for me, but the following does read values in pairs from an ordered list

(can be more than pairs adding extra 'read-lines' :-)

while read x; do
  read y
  echo "$x $y"
done << '___HERE'
X1
Y1
X2
Y2
X3
Y3
___HERE

produces

X1 Y1
X2 Y2
X3 Y3
tripleee
  • 175,061
  • 34
  • 275
  • 318
splaisan
  • 845
  • 6
  • 22
  • 3
    You can also `while read x && read y; do`, which I find makes it a little easier for readers to follow that you're consuming two items per iteration. – Charles Duffy Jul 28 '19 at 14:17
  • This is a way simpler and better answer than anything above. – Ivan Kovtun Oct 07 '19 at 19:32
  • 1
    This is *buggy*. `read x` and `read y` corrupt data with literal backslashes when the `-r` argument is not used; `echo $x $y` corrupts data in a different way, as described in [BashPitfalls #14](http://mywiki.wooledge.org/BashPitfalls#echo_.24foo). – Charles Duffy Apr 22 '20 at 14:03
  • This uses no Bash-only features (though you'll. want to use `read -r` as pointed out above), and is thus portable to any Bourne-compatible shell. – tripleee Aug 14 '21 at 16:17
4

There is a common pattern where you have pairs of files, where one name of the pair can be easily derived from the other. If the file you know the name of is X and the other file is Y, you have the following common use cases.

  • For renaming, Y is X with an extension removed and/or a date stamp added.
  • For transcoding, Y is X with a different extension and perhaps a different directory.
  • For many data analysis tasks, X and Y share some parts of the file name, but have different parameters or extensions.

All of these lend themselves to the same rough code skeleton.

for x in path/to/base*.ext; do
    dir=${x%/*}   # Trim trailing file name, keep dir
    base=${x##*/} # Trim any leading directory

    # In this case, $y has a different subdirectory and a different extension
    y=${dir%/to}/from/${base%.ext}.newext

    # Maybe check if y exists?  Or doesn't exist?
    if [ -e "$y" ]; then
        echo "$0: $y already exists -- skipping" >&2
        continue
    fi

    mv or ffmpeg or awk or whatever "$x" and "$y"
done

The key here is the observation that y can be derived from x with some simple variable substitutions. So you loop over the x values, and figure out the corresponding y value inside the loop.

Here, we have used the shell's built-in ${variable#prefix} and ${variable%suffix} operators to return the variable's value with any leading prefix or trailing suffix, respectively, trimmed off. (There is also ## and %% to match the longest, instead of the shortest, possible match. The expression after # or % is a regular shell glob pattern.) These should usually be all you need, although you frequently see sed or awk scripts even for this trivial job (where really you should usually try to avoid an external process), as well as of course for more demanding transformations.

If you need to loop over x files scattered across different directories, maybe the loop should start with something like

 find dir1 dir2 etc/and/so/forth -type f -name 'x-files*.ext' -print |
 while IFS='' read -r x; do
     :

A commonly seen problem in similar questions is answers which fail to quote $x and $y correctly. Generally, any variable containing a file name should always be in double quotes.

Where X and Y are unrelated, a common solution is to loop over a here document containing the mapping:

while read -r x y; do
    : stuff with "$x" and "$y"
done <<'____HERE'
    first_x_value  first_y_value
    another_x      corresponding_y
    random         surprise
____HERE
tripleee
  • 175,061
  • 34
  • 275
  • 318
  • 1
    I can't really write "X Files" with a completely straight face. Sorry. – tripleee Feb 16 '16 at 14:32
  • This doesn't use any strictly Bash-only functionality, but the "longest match" parameter expansions `##` and `%%` are not available in all shells. In the worst case, maybe use `sed` for the substitutions, or something. – tripleee Aug 14 '21 at 16:22