Scala:以功能方式迭代CSV文件?

时间:2011-09-12 15:31:29

标签: scala csv functional-programming iteration state

我的CSV文件带有注释,这些注释可以提供列名称,其中列在整个文件中发生变化:

#c1,c2,c3
a,b,c
d,e,f
#c4,c5
g,h
i,j

我想提供一种方法来迭代(仅)文件的数据行作为列名称映射到值(所有字符串)。所以上面会变成:

Map(c1 -> a, c2 -> b, c3 -> c)
Map(c1 -> d, c2 -> e, c3 -> f)
Map(c4 -> g, c5 -> h)
Map(c4 -> i, c5 -> j)

文件非常大,因此无法将所有内容读入内存。现在我有一个Iterator类,在hasNext()next()之间保持一些丑陋的状态;我还提供当前行号的访问器和实际的最后一行和注释读取(如果消费者关心字段顺序)。我想尝试以更实用的方式做事。

我的第一个想法是理解:我可以迭代文件的行,用过滤子句跳过注释行。我可以yield一个包含地图,行号等的元组。问题是我需要记住最后看到的列名,以便我可以从中创建地图。对于循环可以理解,试着阻止保持状态,只允许你设置新的val。我从this question了解到我可以更新yield块中的成员变量,但这正是我 想要在我的情况下更新它们的时候!

我可以在迭代子句中调用一个更新状态的函数,但这看起来很脏。那么,在功能风格中执行此操作的最佳方法是什么?滥用理解?哈哈scanLeft?使用图书馆?带出parser combinator大枪?或者功能风格是不是很适合这个问题呢?

7 个答案:

答案 0 :(得分:5)

State Monad FTW!

实际上,我在State monad吮吸。我有一段时间写这篇文章,我有一种强烈的感觉,它可以做得更好。特别是,在我看来traverse是要走的路,但是......

// Get Scalaz on the job
import scalaz._
import Scalaz._

// Some type aliases to make stuff clearer
type Input         = Stream[String]
type Header        = String
type InternalState = (Input, Header)
type Output        = Option[(Header, String)]
type MyState       = State[InternalState, Output]

// Detect headers
def isHeader(line: String) = line(0) == '#'

// From a state, produce an output
def makeLine: (InternalState => Output) = {
    case (head #:: _, _) if isHeader(head) => None
    case (head #:: _, header)              => Some(header -> head)
    case _                                 => None
}

// From a state, produce the next state
def nextLine: (InternalState => InternalState) = {
    case (head #:: tail, _) if isHeader(head) => tail -> head
    case (_ #:: tail, header)                 => tail -> header
    case _                                    => Stream.empty -> ""
}

// My state is defined by the functions producing the next state
// and the output
val myState: MyState = state(s => nextLine(s) -> makeLine(s))    

// Some input to test it. I'm trimming it to avoid problems on REPL
val input = """#c1,c2,c3
a,b,c
d,e,f
#c4,c5
g,h
i,j""".lines.map(_.trim).toStream

// My State/Output Stream -- def to avoid keeping a reference to the head
def stateOutputStream = Stream.iterate(myState(input, "")){ 
        case (s, _) => myState(s) 
    } takeWhile { case ((stream, _), output) => stream.nonEmpty || output.nonEmpty }

// My Output Stream -- flatMap gets rid of the None from the headers
def outputStream = stateOutputStream flatMap { case (_, output) => output }

// Now just get the map
def outputToMap: (Header, String) => Map[String, String] = {
    case (header, line) =>
        val keys = header substring 1 split ","
        val values = line split ","
        keys zip values toMap
}

// And this is the result -- note that I'm still avoiding "val" so memory
// won't leak
def result = outputStream map outputToMap.tupled

答案 1 :(得分:3)

在这里,你可以通过Iteratees做到这一点。该流表示为从Iteratee到Iteratee的函数,因此它在内存中实际上从未实现过。我使用State monad来跟踪最后遇到的标题。

import scalaz._
import Scalaz._
import IterV._

type Header = List[String]
type MyState[A] = State[Header, A]
type Out = Map[String, String]

// Detect headers
def isHeader(line: String) = line(0) == '#'

type Enumeratee[A, B, C] =
  IterV[B, C] => Iteratee[MyState, A, IterV[B, C]]

// Enumerate a list. Just for demonstration.
def enumerateM[M[_]: Monad, E, A]:
  (List[E], Iteratee[M, E, A]) => Iteratee[M, E, A] = {
    case (Nil, i) => i
    case (x :: xs, Iteratee(m)) => Iteratee(for {
      v <- m
      o <- v match {
        case d@DoneM(_, _) => d.pure[M]
        case ContM(k) => enumerateM.apply(xs, k(El(x))).value
      }
    } yield o)
  }

def stateTrans[A]: Enumeratee[String, Map[String, String], A] =
  i => Iteratee(i.fold(
         done = (_, _) => DoneM(i, Empty.apply).pure[MyState],
         cont = k => ContM((x: Input[String]) => x match {
           case El(e) => Iteratee[MyState, String, IterV[Out, A]](for {
             h <- init
             o <- if (isHeader(e))
                    put(e substring 1 split "," toList) map (_ => Empty[Out])
                  else El((h zip (e split ",")).toMap).pure[MyState]
             v <- stateTrans(k(o)).value
           } yield v)
           case Empty() => stateTrans(k(Empty.apply))
           case EOF() => stateTrans(k(EOF.apply))
         }).pure[MyState]
       ))

让我们测试一下并取出输出流的头部:

scala> (enumerateM[MyState, String, IterV[Out, Option[Out]]].apply(
     | List("#c1,c2,c3","a,b,c","d,e,f"), stateTrans(head)).value ! List())
     | match { case DoneM(a, _) => a match { case Done(b, _) => b } }
res0: Option[Out] = Some(Map(c1 -> a, c2 -> b, c3 -> c))

通过将这些东西抽象给辅助函数,可以做得更好。

答案 2 :(得分:2)

这是一个可能的解决方案:

首先看一下Split up a list at each element satisfying a predicate (Scala)的答案,它会给你一个groupPrefix函数。您将获得一个方法groupPrefix,它将列表拆分为列表列表,当项目满足给定谓词时拆分出现。通过这种方式,您可以拆分列表,从每个注释行开始(列定义),然后包含相应的数据

此例程将转换相应映射列表中的一个子列表(以列名开头)。

import scala.collection.immutable.ListMap 
  // to keep the order of the columns. If not needed, just use Map
def toNamedFields(lines: List[String]) : List[Map[String, String]] = {
  val columns = lines.head.tail.split(",").toList // tail to discard the #
  lines.tail.map{line => ListMap(columns.zip(line.split(",")): _*)}
}

使用它,您可以分割线条,获取每个组中的地图,获取一个地图列表列表,您可以将其变为单个列表并展平

groupPrefix(lines){_.startsWith("#")}.map(toNamedFields).flatten

答案 3 :(得分:2)

可能会更优雅,但你会得到演练:

  def read(lines: Iterator[String], currentHeadings: Option[Seq[String]] = None): Stream[Option[Map[String, String]]] = 
    if (lines.hasNext) {
      val l = lines.next
      if (l.startsWith("#"))
        Stream.cons(
          None,
          read(lines, Some(l.tail.split(","))))
      else
        Stream.cons(
          currentHeadings.map(_.zip(l.split(",")).toMap),
          read(lines, currentHeadings))
    } else Stream.cons(None, Stream.Empty)

  def main(args: Array[String]): Unit = {
    val lines = scala.io.Source.fromFile("data.csv").getLines
    println(read(lines).flatten.toList)
  }

打印:

List(Map(c1 -> a, c2 -> b, c3 -> c), Map(c1 -> d, c2 -> e, c3 -> f), Map(c4 -> g, c5 -> h), Map(c4 -> i, c5 -> j))

答案 4 :(得分:2)

这就是Python ......

from collections import namedtuple

def read_shifty_csv(csv_file):
    cols = None
    for line in csv_file:
        line = line.strip()
        if line.startswith('#'):
            cols = namedtuple('cols', line[1:].split(','))
        else:
            yield cols(*line.split(','))._asdict()

如果您宁愿使用元组而不是映射(dict),请删除_asdict()调用。 仅在内存中一次实现一行。

编辑以尝试稍微更具功能性:

from collections import namedtuple
from itertools import imap

def read_shifty_csv(csv_file):
    cols = None
    for line in imap(str.strip, csv_file):
        if line.startswith('#'):
            cols = namedtuple('cols', line[1:].split(','))
        else:
            yield cols(*line.split(','))._asdict()

刚刚删除了line = line.strip()

的邪恶重新分配

答案 5 :(得分:1)

受@ schmichael在功能性Python解决方案上的勇敢努力的启发,这是我试图推动事情走得太远的尝试。我并没有声称它是可维护的,高效的,示范性的或可耻的,但它是有用的:

from itertools import imap, groupby, izip, chain
from collections import deque
from operator import itemgetter, methodcaller
from functools import partial

def shifty_csv_dicts(lines):
    last = lambda seq: deque(seq, maxlen=1).pop()
    parse_header = lambda header: header[1:-1].split(',')
    parse_row = lambda row: row.rstrip('\n').split(',')
    mkdict = lambda keys, vals: dict(izip(keys,vals))
    headers_then_rows = imap(itemgetter(1), groupby(lines, methodcaller('startswith', '#')))
    return chain.from_iterable(imap(partial(mkdict, parse_header(last(headers))), imap(parse_row, next(headers_then_rows))) for headers in headers_then_rows)

好的,让我们打开它。

基本的见解是(ab)使用itertools.groupby来识别从标题到数据行的更改。我们使用参数评估语义来控制操作的顺序。

首先,我们告诉groupby按行是否以'#'开头对行进行分组:

methodcaller('startswith', '#')

创建一个函数,它接受一行并调用line.startswith('#')(它相当于风格上更好但效率更低lambda line: line.startswith('#'))。

因此groupby接受lines的传入迭代,并在返回可迭代的标题行(通常只有一个标题)和可迭代的数据行之间交替。它实际上返回(group_val, group_iter)的元组,在这种情况下,group_valbool,表示它是否是标题。因此,我们在所有元组上执行等效的(group_val, group_iter)[1]来选择迭代器:itemgetter(1)只是一个在你给它的任何东西上运行“[1]”的函数(再次相当于但比lambda t: t[1]更有效率。所以我们使用imapitemgetter返回的每个元组上运行我们的groupby函数来挑选标题/数据迭代器:

imap(itemgetter(1), groupby(lines, methodcaller('startswith', '#')))

我们首先评估该表达式并给它一个名称,因为我们稍后会使用它两次,首先是标题,然后是数据。最外面的电话:

chain.from_iterable(... for headers in headers_then_rows)

逐步完成从groupby返回的迭代器。我们正在狡猾地调用值headers,因为...中的其他一些代码在我们不看时会选择rows,在此过程中推进groupby迭代器。这个外部生成器表达式只会产生标题(记住,它们会改变:标题,数据,标题,数据......)。诀窍是确保在行之前消耗标头,因为它们共享相同的底层迭代器。 chain.from_iterable只是将所有数据行迭代器的结果拼接成一个迭代器以返回它们。

那么我们拼接在一起的是什么?好吧,我们需要取(最后)标题,用每行值拉上它,然后从中做出决定。这样:

last = lambda seq: deque(seq, maxlen=1).pop()

是一个有点脏但有效的黑客来从迭代器中获取最后一个项目,在本例中是我们的标题行。然后我们通过修剪前导#和尾随换行来解析标题,然后拆分,以获取列名列表:

parse_header = lambda header: header[1:-1].split(',')

但是,我们只希望为每个行迭代器执行一次,因为它耗尽了我们的头迭代器(我们现在不想将它复制到某个可变状态,是吗?)。我们还必须确保在行之前使用头迭代器。解决方案是制作部分应用的函数,评估和修复标题作为第一个参数,并将一行作为第二个参数:

partial(mkdict, parse_header(last(headers)))

mkdict函数使用列名作为键,行数据作为值来制作字典:

mkdict = lambda keys, vals: dict(izip(keys,vals))

这给了我们一个冻结第一个参数(keys)并让你传递第二个参数(vals)的函数:正是我们用相同的键创建一堆dicts所需要的和不同的价值观。

要使用它,我们会像您期望的那样解析每一行:

parse_row = lambda row: row.rstrip('\n').split(',')

回想一下next(headers_then_rows)将从groupby返回数据行迭代器(因为我们已经使用了头迭代器):

imap(parse_row, next(headers_then_rows))

最后,我们将部分应用的dict-maker函数映射到解析的行:

imap(partial(...), imap(parse_row, next(headers_then_rows)))

这些都由chain.from_iterable拼接在一起,形成一个又大又快乐,功能齐全的CSV格式。

为了记录,这可能会被简化,我仍然会以@ schmichael的方式做事。但是我学会了解决这个问题的方法,我会尝试将这些想法应用到Scala解决方案中。

答案 6 :(得分:0)

编辑:抓一点,我认为你不需要monad