百度360必应搜狗淘宝本站头条
当前位置:网站首页 > 技术文章 > 正文

Elixir实战: 4 数据抽象 (2)处理层次数据

myzbx 2025-01-21 20:00 15 浏览

4.2 处理层次数据

在本节中,您将扩展 TodoList 抽象以提供基本的 CRUD 支持。您已经通过 add_entry/2 和 entries/2 函数解决了 C 和 R 部分。现在,您需要添加更新和删除条目的支持。为此,您必须能够唯一标识待办事项列表中的每个条目,因此您将首先为每个条目添加唯一的 ID 值。

4.2.1 生成 ID

在向列表添加新条目时,您将自动生成其 ID 值,使用递增的整数作为 ID。要实现这一点,您需要做几件事:

  • 将待办事项列表表示为一个结构体。您需要这样做,因为待办事项列表现在必须保留两条信息:条目集合和下一个条目的 ID 值。
  • 使用条目的 ID 作为键。到目前为止,在将条目存储在集合中时,您使用了条目的日期作为键。您将更改此设置,改为使用条目的 ID。这将使快速插入、更新和删除单个条目成为可能。现在每个键将恰好有一个值,因此您不再需要 MultiDict 抽象。

让我们开始实现这个。以下列表中的代码包含模块和结构定义。

列表 4.11 TodoList 结构 (todo_crud.ex)

defmodule TodoList do
  defstruct next_id: 1, entries: %{}   
 
  def new(), do: %TodoList{}           
  ...
end

描述待办事项列表的结构

创建一个新实例

待办事项列表现在将表示为一个包含两个字段的结构体。字段 next_id 包含将在添加到结构体时分配给新条目的 ID 值。字段 entries 是条目的集合。如前所述,您现在使用的是一个映射,键是条目 ID 值。

在结构体定义期间, next_id 和 entries 字段的默认值被立即指定。因此,在创建新实例时,您不必提供这些。 new/0 函数创建并返回结构体的一个实例。

接下来,是时候重新实现 add_entry/2 函数了。它需要做更多的工作:

  • 设置要添加的条目的 ID。
  • 将新条目添加到集合中。
  • 增加 next_id 字段。

这是代码。

清单 4.12 为新条目自动生成 ID 值 (todo_crud.ex)

defmodule TodoList do
  ...
 
  def add_entry(todo_list, entry) do
    entry = Map.put(entry, :id, todo_list.next_id)   
 
    new_entries = Map.put(                           
      todo_list.entries,                             
      todo_list.next_id,                             
      entry                                          
    )
 
    %TodoList{todo_list |                            
      entries: new_entries,                          
      next_id: todo_list.next_id + 1                 
    }
  end
 
  ...
end

设置新条目的 ID

将新条目添加到条目列表中

更新结构

这里发生了很多事情,所以让我们一步一步来。

在函数体内,您首先使用存储在 next_id 字段中的值更新条目的 id 值。注意您如何使用 Map.put/3 来更新条目映射。输入映射可能不包含 id 字段,因此您无法使用标准的 %{entry | id: next_id} 技术,该技术仅在 id 字段已经存在于映射中时有效。一旦条目被更新,您将其添加到条目集合中,并将结果保存在 new_entries 变量中。

最后,您必须更新 TodoList 结构实例,将其 entries 字段设置为 new_entries 集合,并递增 next_id 字段。基本上,您在结构中进行了复杂的更改,修改了多个字段以及输入条目(因为您设置了其 id 字段)。

对于外部调用者,整个操作将是原子的。要么一切都发生,要么在发生错误的情况下,什么都不会发生。这是不可变性的结果。添加条目的效果仅在 add_entry/2 函数完成并将其结果赋值给变量时对其他人可见。如果出现问题并引发错误,则任何转换的效果将不可见。

值得重申的是,如第 2 章所提到的,新待办事项列表(由 add_entry/2 函数返回的那个)将尽可能与输入的待办事项列表共享内存。

完成了 add_entry/2 函数后,您需要调整 entries/2 函数。这将更加复杂,因为您更改了内部结构。之前,您保持了日期到条目的映射。现在,条目使用 id 作为键进行存储,因此您必须遍历所有条目并返回在给定日期内的条目。

清单 4.13 过滤特定日期的条目 (todo_crud.ex)

defmodule TodoList do
  ...
 
  def entries(todo_list, date) do
    todo_list.entries
    |> Map.values()                                      
    |> Enum.filter(fn entry -> entry.date == date end)   
  end
 
  ...
end

取值

过滤特定日期的条目

此功能首先使用 Map.values/1 从 entries 映射中获取条目。然后,仅使用 Enum.filter/2 获取在给定日期的条目。

您可以检查您的新版本待办事项列表是否正常工作:

$ iex todo_crud.ex
 
iex(1)> todo_list = TodoList.new() |>
          TodoList.add_entry(%{date: ~D[2023-12-19], title: "Dentist"}) |>
          TodoList.add_entry(%{date: ~D[2023-12-20], title: "Shopping"}) |>
          TodoList.add_entry(%{date: ~D[2023-12-19], title: "Movies"})
 
iex(2)> TodoList.entries(todo_list, ~D[2023-12-19])
[
  %{date: ~D[2023-12-19], id: 1, title: "Dentist"},
  %{date: ~D[2023-12-19], id: 3, title: "Movies"}
]

这按预期工作,您甚至可以看到每个条目的 ID 值。还要注意, TodoList 模块的接口与之前的版本相同。您进行了多项内部修改,改变了数据表示,几乎重写了整个模块。然而,模块的客户端不需要进行更改,因为您保持了函数的相同接口。

这并不是革命性的——这是将行为封装在适当选择的接口后面的经典好处。然而,它展示了在处理无状态模块和不可变数据时,如何构建和推理更高级别的类型。

4.2.2 更新条目

现在您的条目已经有了 ID 值,您可以添加额外的修饰操作。让我们实现 update_entry 操作,它可以用于修改待办事项列表中的单个条目。

此功能将接受一个条目 ID 和一个更新器 lambda,该 lambda 将被调用以更新条目。这将类似于 Map.update 。该 lambda 将接收原始条目并返回其修改版本。为了保持简单,如果给定 ID 的条目不存在,该功能不会引发错误。

以下代码片段演示了用法。在这里,您修改一个 ID 值为 1 的条目的日期:

iex(1)> TodoList.update_entry(
          todo_list,
          1,                                     
          &Map.put(&1, :date, ~D[2023-12-20])    
        )

要修改的条目的 ID

修改条目日期

实现如下所示。

清单 4.14 更新条目 (todo_crud.ex)

defmodule TodoList do
  ...
 
  def update_entry(todo_list, entry_id, updater_fun) do
    case Map.fetch(todo_list.entries, entry_id) do
      :error ->                              
        todo_list
 
      {:ok, old_entry} ->                    
        new_entry = updater_fun.(old_entry)
        new_entries = Map.put(todo_list.entries, new_entry.id, new_entry)
        %TodoList{todo_list | entries: new_entries}
    end
  end
 
  ...
end

不可进入—返回未更改的列表

条目存在—执行更新并返回修改后的列表

让我们来分析一下这里发生了什么。首先,您使用 Map.fetch/2 查找具有给定 ID 的条目。如果条目不存在,函数将返回 :error ,否则返回 {:ok, value} 。

在第一种情况下,如果条目不存在,则返回列表的原始版本。否则,您必须调用更新器 lambda 以获取修改后的条目。然后,将修改后的条目存储到条目集合中。最后,将修改后的条目集合存储在 TodoList 实例中并返回该实例。

4.2.3 不可变的层次更新

您可能没有注意到,但在前面的示例中,您对一个不可变的层次结构进行了深度更新。让我们来分析一下当您调用 TodoList .update_entry(todo_list, id, updater_lambda) 时发生了什么:

  1. 您将目标条目放入一个单独的变量中。
  2. 您调用更新程序,它将修改后的条目返回给您。
  3. 您调用 Map.put 将修改后的条目放入条目集合中。
  4. 您返回包含新条目集合的待办事项列表的新版本。

请注意,第 2、3 和 4 步是您转换数据的步骤。每个步骤都会创建一个包含转换数据的新变量。在每个后续步骤中,您将使用这些数据并更新其容器,再次通过创建其转换版本来实现。

这就是如何处理不可变数据结构。如果你有层次化的数据,你不能直接修改位于其树深处的部分。相反,你必须沿着树向下走到需要修改的特定部分,然后对其及其所有祖先进行转换。结果是整个模型的一个副本(在这种情况下,是待办事项列表)。如前所述,两个版本——新版本和旧版本——将尽可能共享内存。

提供的帮助者

尽管所提出的技术有效,但对于更深的层级可能会变得繁琐。请记住,要更新层级深处的一个元素,您必须走到该元素并更新其所有父级。为了简化这一过程,Elixir 提供了对更优雅的深层次层级更新的支持。

让我们看一个基本的例子。假设待办事项列表被表示为一个简单的映射,其中键是 ID,值是由字段组成的普通映射。让我们创建一个这样的待办事项列表的实例:

iex(1)> todo_list = %{
  1 => %{date: ~D[2023-12-19], title: "Dentist"},
  2 => %{date: ~D[2023-12-20], title: "Shopping"},
  3 => %{date: ~D[2023-12-19], title: "Movies"}
}

现在,假设你改变主意,想去剧院而不是看电影。原始结构可以优雅地使用 Kernel.put_in/2 宏进行修改:

iex(2)> put_in(todo_list[3].title, "Theater")       
 
%{
  1 => %{date: ~D[2023-12-19], title: "Dentist"},
  2 => %{date: ~D[2023-12-20], title: "Shopping"},
  3 => %{date: ~D[2023-12-19], title: "Theater"}    
}

层级更新

条目标题已更新。

这里发生了什么?在内部, put_in/2 做的事情类似于你所做的。它递归地遍历到所需的元素,进行转换,然后更新所有父级。请注意,这仍然是一个不可变操作,这意味着原始结构保持不变,你必须将结果赋值给一个变量。

要能够进行递归遍历, put_in/2 需要接收源数据和目标元素的路径。在前面的示例中,源数据提供为 todo_list ,路径指定为 [3].title 。宏 put_in/2 然后沿着该路径向下遍历,在上升过程中重建新的层次结构。

值得注意的是,Elixir 提供了类似的替代方案用于数据检索和更新,形式为 get_in/2 、 update_in/2 和 get_and_update_in/2 宏。这些是宏的事实意味着您提供的路径在编译时被评估,无法动态构建。

如果您需要在运行时构建路径,有相应的函数可以将数据和路径作为单独的参数接受。例如,Elixir 还包括 put_in/3 宏,可以如下使用:

iex(3)> path = [3, :title]
 
iex(4)> put_in(todo_list, path, "Theater")   

使用在运行时构建的路径

函数和宏,例如 put_in ,依赖于 Access 模块,该模块允许您处理键值结构,例如映射。您还可以创建自己的抽象以与 Access 一起使用。您需要实现 Access 合约所需的几个函数,然后 put_in 及相关宏和函数将知道如何与您自己的抽象一起工作。有关更多详细信息,请参阅官方 Access 文档 (https://hexdocs.pm/elixir/Access.xhtml)。

练习:删除条目

您的 TodoList 模块几乎完成。您已经实现了创建 ( add_entry/2 )、检索 ( entries/2 ) 和更新 ( update_entry/3 ) 操作。最后要实现的是 delete_entry/2 操作。这很简单,留给您作为练习。如果您遇到困难,解决方案在源文件 todo_crud.ex 中提供。

4.2.4 迭代更新

到目前为止,您一直在手动逐个进行更新。现在,是时候实施迭代更新了。想象一下,您有一个原始列表描述条目:

$ iex todo_builder.ex
 
iex(1)> entries = [
          %{date: ~D[2023-12-19], title: "Dentist"},
          %{date: ~D[2023-12-20], title: "Shopping"},
          %{date: ~D[2023-12-19], title: "Movies"}
        ]

现在,您想创建一个包含所有这些条目的待办事项列表实例:

iex(2)> todo_list = TodoList.new(entries)

显然, new/1 函数执行了待办事项列表的迭代构建。你如何实现这样的函数?事实证明,这很简单。

清单 4.15 迭代构建待办事项列表 (todo_builder.ex)

defmodule TodoList do
  ...
 
  def new(entries \\ []) do
    Enum.reduce(
      entries,
      %TodoList{},                       
      fn entry, todo_list_acc ->         
        add_entry(todo_list_acc, entry)
      end
    )
  end
 
  ...
end

初始累加器值

迭代更新累加器

要迭代地构建待办事项列表,您依赖于 Enum.reduce/3 。回想一下第 3 章, reduce 用于将可枚举的内容转换为其他任何内容。在这种情况下,您将一组原始的 Entry 实例转换为 TodoList 结构的一个实例。因此,您调用 Enum.reduce/3 ,将输入列表作为第一个参数,将新的结构实例作为第二个参数(初始累加器值),以及在每一步中调用的 lambda。

每个输入列表中的条目都会调用 lambda。它的任务是将条目添加到当前累加器( TodoList 结构)中,并返回新的累加器值。为此,lambda 委托给已经存在的 add_entry/2 函数,反转参数顺序。参数需要反转,因为 Enum.reduce/3 调用 lambda,传递迭代元素(条目)和累加器( TodoList 结构)。相比之下, add_entry 接受一个结构和一个条目。

注意,您可以借助捕获运算符使 lambda 定义更加简洁:

def new(entries \\ []) do
  Enum.reduce(
    entries,
    %TodoList{},
    &add_entry(&2, &1)    
  )
end

反转参数的顺序并委托给 add_entry/2

无论您使用这个版本还是之前的版本完全取决于您的个人喜好。

4.2.5 练习:从文件导入

现在,是时候让你练习一下了。在这个练习中,你将从一个以逗号分隔的文件中创建一个 TodoList 实例。

假设您在当前文件夹中有一个 todos.csv 文件。文件中的每一行描述一个单独的待办事项条目:

2023-12-19,Dentist
2023-12-20,Shopping
2023-12-19,Movies

您的任务是创建一个额外的模块 TodoList.CsvImporter ,该模块可以用于从文件内容创建一个 TodoList 实例:

iex(1)> todo_list = TodoList.CsvImporter.import("todos.csv")

为了简化任务,假设文件始终可用且格式正确。还假设逗号字符不会出现在条目标题中。

这通常不难做到,但可能需要一些破解和实验。以下是一些提示,可以引导您朝正确的方向前进。

首先,创建一个具有以下布局的单个文件:

defmodule TodoList do
  ...
end
 
defmodule TodoList.CsvImporter do
  ...
end

始终以小步骤进行工作。实现部分计算,然后使用 IO.inspect/1 将结果打印到屏幕上。我无法过分强调这一点的重要性。此任务需要一些数据管道处理。以小步骤工作将使您能够逐步前进,并验证您是否在正确的轨道上。

您应该采取的一般步骤如下:

  1. 打开一个文件并逐行查看,删除每行中的 \n 。提示:使用 File.stream!/1 、 Stream.map/2 和 String.trim_trailing/2 。你在第三章中做过这个,当时我们谈论流,在过滤长度超过 80 个字符的行的示例中。
  2. 使用 Stream.map ,将从上一步获得的每一行转换为待办事项条目。
    1. 将该行转换为 [date_string, title] 列表,使用 String.split/2 。
    2. 将日期字符串转换为日期,使用 Date.from_iso8601! ( https://hexdocs.pm/elixir/Date.xhtml#from_iso8601!/2)。
    3. 创建待办事项列表条目(形状为 %{date: date, title: title} 的地图)。

步骤 2 的输出是一个可枚举的待办事项条目。将该可枚举对象传递给您最近实现的 TodoList.new/1 函数。

在每个步骤中,您将接收一个可枚举对象作为输入,转换每个元素,并将生成的可枚举对象传递给下一个步骤。在最后一步,生成的可枚举对象被传递给已经实现的 TodoList.new/1 ,并创建待办事项列表。

如果你以小步骤工作,就更难迷失方向。例如,你可以先打开一个文件并将每一行打印到屏幕上。然后,尝试去除每一行末尾的换行符并将它们打印到屏幕上,依此类推。

在每一步转换数据时,您可以使用 Enum 函数或 Stream 模块中的函数。开始时使用 Enum 模块中的急切函数可能会更简单,并使整个过程正常工作。然后,尽量用 Stream 对应的函数替换尽可能多的 Enum 函数。请回忆第 3 章, Stream 函数是惰性和可组合的,这可以减少操作所需的中间内存量。如果您迷路了,解决方案在 todo_import.ex 文件中提供。

与此同时,我们几乎完成了对更高级抽象的探索。我们将简要讨论的最后一个主题是 Elixir 的多态实现方式。

相关推荐

斐波那契时钟:据说智商太低的人看不懂,你敢来挑战吗?

如果你在一个公共场合,看到了桌上亮着的东西,你会以为它是什么?小夜灯?恭喜你,回答错误。它是Fibonacci钟,专为追求与众不同的“怪胎们”准备。它外表精美,甚至看不出这是一个钟,但它真的是个钟,只...

曾都区文峰学校二年级数学组开展钟表制作主题活动

为了使学生更加直观地认识钟表、感知钟表,曾都区文峰学校二年级数学组开展了“小钟面大创意”钟表制作主题活动。孩子们大胆设计、精心制作,每一个作品都充满了创意与童趣。 滴答滴答滴答,小小时钟在说话。它都...

不能更酷!游戏机改造的报时钟表(游戏机能改成正常机吗)

当家里的游戏机逐渐过时的时候,你会把它怎么办?无论是卖掉还是封存珍藏都是不错的办法,不过Rurue0111有更酷的主意:将PlayStation改造成时钟。初代PS正脸有一个圆圆的硕大光驱盒盖,别...

计量小知识来了!古代没有钟表,夜晚是如何计算时间的?

古人把一昼夜分为十二个时辰,用十二地支名加上“时”字表示。即子时、丑时、寅时、卯时、辰时、巳时、午时、未时、申时、酉时、戌时、亥时。每一时刻相当于今天的两个小时。这十二时辰与现今计时法的关系是:子时-...

「爱」的挂钟(爱的时钟)

鸳鸯和天鹅算是鸟类中最会秀恩爱的了吧,以它们为元素做设计也应该是件很优雅的事。台湾的好事(haoshi)工作室成立于2009年。设计师坚持纯洁与和平的设计理念,将生活中抽象的好事物以艺术化的形式呈...

亚洲最大欧米茄时钟亮相北京王府井百货 钟面直径约7.9米

亚洲最大欧米茄(OMEGA)时钟正式亮相北京地标性建筑王府井百货(专题阅读)大楼。作为迄今为止亚洲最大的欧米茄时钟,甫一亮相即吸引到众多游客的目光。至此,位于北京最繁华地带的王府井大街将被欧米茄时钟...

初一数学难点“线段与角”,老师手把手教你画图,保证学会

期中考试已过去,学生们的学习进了一个新的阶段,每个科目的老师又开始兢兢业业开始了他们的讲授之路。现以初一数学为例,具体说说现阶段的数学学习什么?如何去高效学习?在现阶段,学生们开始学习了一些简单的几何...

科技感十足的悬浮时钟(悬浮 时钟)

说起来磁悬浮已经不是什么新鲜事儿了,自从有了磁悬浮列车,好像所有的东西都可以跟磁悬浮扯上关系。一个来自瑞典的设计团队FLYTE设计了一款叫做STORY的时钟,利用磁悬浮技术,让时钟的指针飞了起来~▽设...

一上数学 第七单元 认识钟表 逐字稿

(bluehouse456全文整理)同学们大家好,老师,这里准备了一些图片,你知道是什么吗?是钟表。钟表在我们的生活中经常遇到,你知道钟表有什么作用吗?是的,钟表可以帮助人们计时安排一天的工作和学习...

人教版数学一年级上册第七单元《认识钟表》知识要点:

一、认识钟面1.钟面结构钟面上有12个数字,按顺时针方向排列,代表1到12时。分针:又细又长的指针,转动较快,指示分钟。时针:又粗又短的指针,转动较慢,指示小时。指针转动方向为顺时针(从左到右)。2...

浔阳小学一年级数学组举行钟表制作活动

九江新闻网讯(伍巧红)12月23日,浔阳小学一年级数学组组织学生进行制作钟表的活动。孩子们利用双休日与父母共同参与了这项活动。这次钟面设计手工制作活动,属于数学“设计与应用”领域。钟表与学生的生活息息...

时钟指针夹角计算公式(时钟夹角万能公式)

关于时钟指针夹角问题,小学应该有一定的认识,一些特殊情况下的夹角,学生能顺利求出。在初一数学《角》的教学中,我们对夹角问题会有更深入的了解。设定钟面时间为a时b分,此时,时针与分针夹角是多少呢?有没有...

雨城区四小教育集团汉碑校区一年级数学组开展“创意钟面”制作比赛

四川新闻网雅安5月6日讯为丰富学生的课余生活,培养创新思维和动手操作能力,复习巩固新学的《钟面的认识》,使学生进一步掌握“整时、几时半、大约几时”等知识点。近日,雨城区四小教育集团汉碑校区一年级数学...

一大波奇奇怪怪的钟表来袭,你见过几个?

别再问时间都去哪了,其实时间都去了设计师的脑洞里……No.1音乐时钟DINN意大利设计师AlessandroZambelli为钟表品牌Diamantini&Domeniconi设...

瑞士国铁钟表Mondaine:分秒不差的精准,穿越七十年的经典

在瑞士,时间不仅是数字,更是一种艺术。当你踏入瑞士任何一个火车站,目光一定会被站台上那简洁优雅的挂钟吸引——纯白的钟面、清晰的黑色刻度,再配上一根醒目的红色圆头秒针,仿佛在无声地诉说着瑞士人对精准与美...