펄 튜토리알의 이번 내용은 어떻게 배열에서 고유한 값들만 남도록 할 수 있는가입니다.

Perl 5에는 배열에서 중복된 값을 걸러내는 내장 함수는 없지만, 여러가지 방법으로 해결할 수 있습니다.

List::MoreUtils

상황에 따라서 다르겠지만, 아마 가장 간단한 방법은 CPAN에 있는 List::MoreUtils 모듈이 제공하는 uniq 함수를 사용하는 것입니다.

use List::MoreUtils qw(uniq);

my @words = qw(foo bar baz foo zorg baz);
my @unique_words = uniq @words;

다음은 전체 예제 코드입니다:

use strict;
use warnings;
use 5.010;

use List::MoreUtils qw(uniq);
use Data::Dumper qw(Dumper);

my @words = qw(foo bar baz foo zorg baz);

my @unique_words = uniq @words;

say Dumper \@unique_words;

결과는 다음과 같습니다:

$VAR1 = [
        'foo',
        'bar',
        'baz',
        'zorg'
      ];

재미있게도 이 모듈에는 distinct라는 함수도 있는데, 이 함수는 결국 uniq 함수의 또다른 이름일 뿐입니다.

이 모듈을 사용하려면 CPAN을 통해 설치를 해야 합니다.

직접 만든 uniq

어떤 이유에서든 위의 모듈을 설치할 수 없다면, 또는 모듈을 로드하는 오버헤드가 너무 크다고 생각한다면, 동일한 일을 하는 매우 짧은 표현식이 있습니다:

my @unique = do { my %seen; grep { !$seen{$_}++ } @data };

이것은, 모르는 사람들에게는 신비스럽게 보일 수 있습니다. 그러니 uniq 서브루틴을 따로 만들어서, 코드의 다른 부분에서는 이 서브루틴을 사용하는 것을 권장합니다:

use strict;
use warnings;
use 5.010;

use Data::Dumper qw(Dumper);

my @words = qw(foo bar baz foo zorg baz);

my @unique = uniq( @words );

say Dumper \@unique_words;

sub uniq {
  my %seen;
  return grep { !$seen{$_}++ } @_;
}

직접 만든 uniq 상세한 설명

위의 예제를 이렇게 끝내버리고 가자니 아깝습니다. 좀 더 설명해볼까 합니다. 더 쉬운 형태부터 시작해 봅시다:

my @unique;
my %seen;

foreach my $value (@words) {
  if (! $seen{$value}) {
    push @unique, $value;
    $seen{$value} = 1;
  }
}

여기서는 통상적인 foreach 루프를 사용하여 원 배열의 원소들을 하나씩 순회하고 있습니다. 또한 %seen 해시를 사용하고 있는데, 해시의 멋진 점은 해시의 키들은 중복되지 않는다는 점입니다.

처음 시작할 때는 해시는 비어 있기 때문에, 첫번째 "foo"를 만났을 때 $seen{"foo"}는 아직 존재하지 않고, 그 값은 undef이며 이 값은 Perl에서는 거짓으로 간주됩니다. 이것은 아직 "foo"란 값을 본 적이 없다는 뜻이고, 고유한 값들을 저장하기 위해 마련한 @uniq 배열의 제일 뒤에 이 값을 추가합니다.

또한 $seen{"foo"}의 값을 1로 설정합니다. 사실 Perl에서 "참"으로 간주되는 값이라면 뭐든 상관없습니다.

다음번에 동일한 문자열을 만나게 되면, 이미 %seen 해시 안에는 그 키가 있고, 그 키에 대응되는 값은 참이고, 따라서 if 조건을 만족시키지 못하며, 결국 결과 배열에 그 값을 중복해서 push하지 않게 됩니다.

직접 만든 uniq 짧게 줄이기

먼저 1을 할당하던 $seen{$value} = 1; 부분을 증가연산자를 쓰도록 $seen{$value}++으로 고칩니다. 모든 양수는 다 참으로 평가되므로 이렇게 고쳐도 앞에서 본 해결책과 동작 원리는 달라지지 않습니다. 그러나 이렇게 고치면 "봤다는 표시"를 하는 부분을 if 조건 안에 삽입할 수 있습니다. 이 때 후위 증가 연산자(전위 증가 연산자가 아니라)를 써야 한다는 것이 중요합니다. 그래야 일단 조건식을 평가한 이후에 증가 연산이 수행되게 됩니다. 어떤 값을 처음 만났을 때는 조건식이 참이 되고, 그 이후부터는 거짓이 될 것입니다.

my @unique;
my %seen;

foreach my $value (@data) {
  if (! $seen{$value}++ ) {
    push @unique, $value;
  }
}

처음보다 짧아졌지만, 좀 더 개선할 수 있습니다.

grep을 사용하여 중복된 값 걸러내기

Perl의 grep 함수는 Unix의 유명한 grep 명령을 일반화한 것입니다.

이것은 기본적으로 필터입니다. 우변에 배열을, 블럭 안에 표현식을 적으면, grep 함수는 배열의 원소를 하나씩 꺼내어 Perl의 디폴트 스칼라 변수$_에 넣고, 블럭 내의 코드를 실행합니다. 블럭의 실행 결과가 참이 되면 이 원소는 통과하고, 거짓이 된다면 이 원소는 걸러집니다.

이 원리를 이용하여 다음과 같은 표현식을 만듭니다:

my %seen;
my @unique = grep { !$seen{$_}++ } @words;

'do' 또는 'sub'로 감싸기

마지막으로 할 일은, 위의 두 구문을 do 블럭 안에 넣거나

my @unique = do { my %seen; grep { !$seen{$_}++ } @words };

또는, 더 좋은 방법은, 용도를 나타내는 이름을 붙여서 함수로 만드는 것입니다:

sub uniq {
  my %seen;
  return grep { !$seen{$_}++ } @_;
}

직접 만든 uniq - 2라운드

Prakash Kailasa가 uniq를 구현하는 더 짧은 형태를 제시했습니다. Perl 버전이 5.14 또는 그 이후 버전이어야 하며, 원소들의 순서를 보존할 필요가 없는 경우에 사용할 수 있습니다.

인라인 코드로는:

my @unique = keys { map { $_ => 1 } @data };

서브루틴으로 만든다면:

my @unique = uniq(@data);
sub uniq { keys { map { $_ => 1 } @_ } };

이 표현식을 분해해 봅시다:

map을 쓰는 문법은 grep과 유사합니다: 블럭과 배열(또는 값들의 리스트)을 받습니다. map은 배열의 각 원소를 순회하면서, 블럭을 실행하고 그 결과를 왼쪽으로 전달합니다.

위와 같은 경우, 배열의 모든 원소값에 대하여 map은 그 값과 그 뒤를 이어 1을 전달합니다. fat comma라고도 불리는 =>는 결국 콤마임을 기억하세요. @data가 ('a', 'b', 'a')였다면, 다음 식은 ('a', 1, 'b', 1, 'a', 1)을 반환할 것입니다.

map { $_ => 1 } @data

저 식을 해시에 할당한다면, 원래의 데이타값들은 키가 되고, 각 키에 대응되는 값은 1인 해시를 얻게 됩니다. 다음의 코드를 실행해보면:

use strict;
use warnings;

use Data::Dumper;

my @data = qw(a b a);
my %h = map { $_ => 1 } @data;
print Dumper \%h;

이런 결과를 얻게 됩니다:

$VAR1 = {
          'a' => 1,
          'b' => 1
        };

만약, 위 식을 해시에 할당하는 대신 중괄호로 둘러싸면, 익명 해시의 레퍼런스를 얻을 수 있습니다.

{ map { $_ => 1 } @data }

직접 해 보면:

use strict;
use warnings;

use Data::Dumper;
my @data = qw(a b a);
my $hr = { map { $_ => 1 } @data };
print Dumper $hr;

해시의 내용을 출력할 때 키들의 순서는 달라질 수 있지만, 그 외에는 앞의 코드에서와 동일하게 출력될 것입니다.

마지막으로, Perl 5.14부터는 keys 함수를 해시 레퍼런스에 대해서도 호출할 수 있습니다. 따라서 다음과 같이 쓸 수 있고:

my @unique = keys { map { $_ => 1 } @data };

@data에서 고유한 값들을 얻을 수 있습니다.

실습

다음 파일이 주어졌을 때 고유값들을 출력해보세요:

input.txt:

foo Bar bar first second
Foo foo another foo

다음과 같이 출력되어야 합니다:

foo Bar bar first second Foo another

실습 2

이번에는 대소문자를 구분하지 않고 중복되는 것을 걸러봅시다.

다음과 같이 출력되어야 합니다:

foo Bar first second another