Read a few lines from a file

How would you get more than one line from a file? In the original Effective Perl blog that Joseph set up to support the first edition of Effective Perl Programming, he shows two possible techniques. One uses a foreach:


my @lines;
for (my $i = 0; $i < 10; $i++) {
  my $line = <STDIN>;
  push @lines, $line;
}

The other uses a map, being careful to get the line input operator in scalar context to read one line per element:

my @lines = map scalar(<STDIN>), 1..10;

Suppose that you want to do this on the command line, maybe because you don’t know that there is a such thing as head or tail?

First, as an Effective Perler, no matter what you choose, you want to hide it behind a subroutine, then only use that subroutine for the task. That way, if you find a better way to do it, you only have one place to fix it. In this technique, you ensure that $n is not 0 or less than 0, including non-numeric strings, which would otherwise raise a warning:

sub read_n_lines {
	my( $n ) = @_;
	no warnings 'numeric';
	return if $n <= 0;
	my @lines = map scalar(<STDIN>), 1..$n;
	}

Perhaps you want to change, that, though, so you can get either a list or an array reference. You can check that with wantarray (Item 45. Use wantarray to write subroutines returning lists). This gets a bit more complicated. In the previous example, you returned the empty list if $n was zero or not a number. Now you have to handle that special case differently because it needs to respect the contextual behavior too. First, you make the list, even if it is the empty list, then you decide how to return it. This examples uses the do-given feature in Perl 5.14:

use 5.014;

open my $fh, '<', '/etc/passwd';

my $lines = read_n_lines( $fh, 5 );

say @$lines;

sub read_n_lines {
	my( $fh, $n ) = @_;

	my @lines = do {
		if( $n <= 0 ) { () }
		else          { map scalar <$fh>, 1..$n }
		};
	
	return do {
		given( wantarray ) {
			when( ! defined ) {  ()     }
			when( !! $_ )     {  @lines }
			default           { \@lines }
			}
		};
	}

There are a few other things that you could add to this, such as checking the filehandle and handling files that end before you can read the specified number of lines.

That’s fine. It’s all wrapped in a subroutine, but you can do better. What if you always want to read 5 lines from a file? You don’t want to constantly type read_n_lines( $fh, 5 ) everywhere. Just like you hid the behavior behind a subroutine, you also want a single place to specify the number of lines to read or the filehandle to use. To do that, you can create a generator to make a special purpose closure:

use 5.014;
use warnings;

use Scalar::Util qw(openhandle);

my $filename = ...;
my $reader = create_read_n_lines( $filename, 15 );

my $lines = $reader->();
say @$lines;

sub create_read_n_lines {
	my( $either, $n ) = @_;

	my $fh = do {
		given( $either ) {
			when( openhandle($either) ) { $either }
			default {
				open my $fh, '<', $either or die "$!";
				$fh;
				}
			}
		};
	
	sub {
		return if eof($fh); # maybe issue a warning

		my @lines = do {
			no warnings 'numeric';
			if( $n == 0 ) { () }
			else          { map scalar <$fh>, 1..$n }
			};
		
		return do {
			given( wantarray ) {
				when( ! defined ) { () }
				when( !! $_ )     { @lines  }
				default           { \@lines }
				}
			};
		};
	}

That looks more complicated, but most of the previous example moved into create_read_n_lines, including the parts to open the file. Now you only need to know the filename. That’s better right?

What happens if you want to pass create_read_n_lines an existing filehandle, such as STDIN, DATA, or something else? You could backtrack on the interface so the argument always has to be a filehandle, but if you’re going to do this completely, you need to check that the argument actually is a filehandle. If you are going to do that, you might as well take both a filename or a filehandle:

use 5.014;
use Scalar::Util qw(openhandle);

...;

sub create_read_n_lines {
	my( $either, $n ) = @_;

	my $fh = do {
		if( openhandle($either) ) { $either }
		elsif( -e $either ) {
			open my $fh, '<', $either or die "$!";
			$fh;
			}
		else { ... }
		};

	...;
	}

There’s a lot more that you can add to this, such as end-of-file handling so you get a sensible return value when you have no more lines to read or you don’t get trying to read lines once you reach the end of the file, but we’ll stop here.

Even though you’ve done a lot of work in the subroutine, once that is done, the user level part is simple:

my $reader = create_read_n_lines( $filename, 6 );

my @list = $reader->();