2024年4月

前言

缓存在程序中扮演着提升性能、降低资源消耗、改善用户体验等重要角色,是构建高效、可伸缩、稳定的系统不可或缺的重要组成部分。今天大姚给大家分享一款.NET开源(基于MIT license)、强大、易于使用的缓存框架:FusionCache。

框架介绍

FusionCache是一个用于构建高效缓存系统的.NET框架,旨在提供简单易用、高性能和可靠的缓存解决方案。支持内存缓存、分布式缓存、http 缓存、CDN、浏览器缓存、离线缓存等等。

框架主要功能

框架具有自动防护缓存奔溃、分布式第二级缓存、软/硬超时处理、安全失败机制、后端通知、依赖注入和构建器支持、OpenTelemetry支持、完全同步/异步支持、事件机制等等功能。

看看框架官方描述:

框架源代码

框架Packages

创建一个控制台应用

我们创建一个
FusionCacheExercise
控制台应用来作为本篇文章的示例项目。

安装FusionCache Nuget包

在Nuget包管理器中搜索:
ZiggyCreatures.FusionCache
进行安装。

创建PersonInfo类

    public class PersonInfo
    {
        public string UserName { get; set; }

        public int Age { get; set; }

        public string Nationality { get; set; }

        public string CacheMsg { get; set; }
    }

创建FusionCacheService

    public class FusionCacheService
    {
        private readonly IFusionCache _cache;

        public FusionCacheService(IFusionCache cache)
        {
            _cache = cache;
        }

        public async Task<PersonInfo> GetValueAsync(string key)
        {
            var cachedValue = await _cache.GetOrDefaultAsync<PersonInfo>(key).ConfigureAwait(false);
            if (cachedValue != null)
            {
                cachedValue.CacheMsg = "缓存中的值";
                return cachedValue;
            }
            else
            {
                //从数据库或其他数据源获取值
                var value = GetValueFromDataSource(key);
                //将值存入缓存,设置过期时间等
                await _cache.SetAsync(key, value, TimeSpan.FromMinutes(10)).ConfigureAwait(false);
                return value;
            }
        }

        private PersonInfo GetValueFromDataSource(string key)
        {
            var personInfo = new PersonInfo
            {
                UserName = "追逐时光者",
                Age = 18,
                Nationality = "中国",
                CacheMsg = "默认值"
            };
            return personInfo;
        }
    }

Program中调用

    internal class Program
    {
        static void Main(string[] args)
        {
            //创建服务集合
            var services = new ServiceCollection();

            //服务注册
            services.AddScoped<FusionCacheService>();
            var entryOptions = new FusionCacheEntryOptions().SetDuration(TimeSpan.FromMinutes(10));
            services.AddFusionCache()
                .WithDefaultEntryOptions(entryOptions)
                .WithPostSetup((sp, c) =>
                {
                    c.DefaultEntryOptions.Duration = TimeSpan.FromMinutes(5);
                });

            using var serviceProvider = services.BuildServiceProvider();

            var myService = serviceProvider.GetRequiredService<FusionCacheService>();

            for (int i = 0; i < 2; i++)
            {
                var value = myService.GetValueAsync("FusionCacheExerciseKey").Result;
                Console.WriteLine($"{value.CacheMsg} {value.UserName},{value.Age},{value.Nationality}");
            }
        }
    }

项目源码地址



更多项目实用功能和特性欢迎前往项目开源地址查看

最近一段时间在研究AI技术在.Net平台的使用,目前AI绝大部分是使用Python开发,偶然一次在头条看到一篇ML.NET的介绍,是Net平台下开放源代码的跨平台机器学习框架。ML.NET详细介绍

https://dotnet.microsoft.com/zh-cn/apps/machinelearning-ai/ml-dotnet

一开始学习的是图像分类和目标检测,整个ML.NET学习过程中走了不少弯路;目标检测最开始使用VS插件ML.NET Model Builder进行数据训练,发现执行效率低下。使用Vott进行图片标注发现也有不少bug,视频文件标注导出后文件路径识别不了。最后,找到了一个效率很高的方式,使用yolo导出onnx模型,yolo数据集使用Python训练和导出onnx,最后在.Net下进行调用即可。

一、车牌识别实现基本步骤

1. 数据标注,可以使用LabImg或其他标注工具

2.训练数据,训练车牌样式,训练文字和颜色ORC识别

3.导出onnx格式模型

4.使用ML.NET调用模型

二、整合到IoTBrowser

IoTBrowser增加Dynamic Api插件框架,另外找了一个Yolov5Net包,默认支持Net6,后面移植到.Net Framework下。

C#调用代码很简单

public AjaxResponse CarNo(string inArgs) {
var ar = new Infrastructure.Web.AjaxResponse();
var obj = Newtonsoft.Json.JsonConvert.DeserializeObject<dynamic>(inArgs);
var path = string.Empty;
var beginTime = DateTime.Now;
if (obj.path != null)
{
path = obj.path;
}
var image = System.Drawing.Image.FromFile(path);
var predictions = yolo.Predict(image);

if (predictions.Count < 1) {
ar.Error("没有检测到车牌");
return ar;
}
foreach (var prediction in predictions) // iterate predictions to draw results
{
double score = Math.Round(prediction.Score, 2);
var labelRect = prediction.Rectangle;
var twoLayers = (labelRect.Height / labelRect.Width) > 0.5;
//定义截取矩形
System.Drawing.Rectangle cropArea = new System.Drawing.Rectangle((int)labelRect.X < 0 ? 0 : (int)labelRect.X, (int)labelRect.Y < 0 ? 0 : (int)labelRect.Y, (int)labelRect.Width, (int)labelRect.Height);
//定义Bitmap对象
System.Drawing.Bitmap bmpImage = new System.Drawing.Bitmap(image);
//进行裁剪
System.Drawing.Bitmap bmpCrop = bmpImage.Clone(cropArea, bmpImage.PixelFormat);
//保存成新文件
//bmpCrop.Save(Path.Combine(path, (fileName + "_" + dtNow + num + "_clone.png")), ImageFormat.Png);

var yoloOcrpredictions = yoloOcr.Predict(bmpCrop);
if (yoloOcrpredictions.Length > 0)
{
ar.Data = (new {carNo = yoloOcrpredictions[0] ,color = yoloOcrpredictions[1] });
}
}
return ar;
}

js端调用更简单

var filePath ="";
var ar = await dds.dynamic.api.exectuce({
pluginName: "CarDetectApi",
actionName: "CarNo",
actionData: JSON.stringify({
path: filePath
})
})
if (ar.Success && ar.Data) {
var data = ar.Data;
self.resultInfo = data.carNo + "--" + data.color
} else {
self.resultInfo = ar.Message;
}

三、实现效果

支持的格式:

1.图片绝对文件路径

2.RTMP协议取帧识别

3.Mp4或ts文件
取帧识别

最近,群里聊到了一个很有意思的布局效果。大致效果如下所示,希望使用 CSS 实现如下所示的布局效果:

正常而言,我们的 HTML 结构大致是如下所示:

<div class="g-container">
    <div class="g-nav">
        <ul>
            <li>Tab 1</li>
            <li>Tab 2</li>
            <li>Tab 3</li>
            <li>Tab 4</li>
        </ul>
    </div>
    <div class="g-main">
        <ul class="g-content">
            <li>...</li>
            <li>...</li>
            <li>...</li>
            <li>...</li>
        </ul>
    </div>
</div>

对于 Hover 导航 Tab 时候的内容切换,暂且不谈。本文,我们核心想探讨的是两个点:

  1. 一是对于如下所示的不规则布局,应该如何实现:

并且,这里我们可能还需要给它加上阴影效果:

  1. 如何配合 Hover 动作,实现整个切换效果

带着这两个问题,我们一起来尝试慢慢把这个效果实现。

借助伪元素实现不规则按钮

首先,我们需要实现这个效果:

这个,其实在很多篇文章都有提及过:

想一想,这里其实就是竖向的 Chrome 分 Tab 的效果:

像是这样:

我们对这个按钮形状拆解一下,这里其实是 3 块的叠加:

只需要想清楚如何实现两侧的弧形三角即可。这里还是借助了渐变 --
径向渐变
,其实他是这样,如下图所示,我们只需要把黑色部分替换为透明即可,使用两个伪元素即可:

代码如下:

<div class="outside-circle"></div>
.outside-circle {
    position: relative;
    background: #e91e63;
    border-radius: 10px 10px 0 0;

    &::before {
        content: "";
        position: absolute;
        width: 20px;
        height: 20px;
        left: -20px;
        bottom: 0;
        background: #000;
        background:radial-gradient(circle at 0 0, transparent 20px, #e91e63 21px);
    }
    &::after {
        content: "";
        position: absolute;
        width: 20px;
        height: 20px;
        right: -20px;
        bottom: 0;
        background: #000;
        background:radial-gradient(circle at 100% 0, transparent 20px, #e91e63 21px);
    }
}

即可得到:

我们照葫芦画瓢,即可非常轻松的实现竖向的相同的效果,示意图如下:

利用 drop-shadow 实现按钮阴影

好,接下来,我们需要给按钮添加上阴影效果,像是这样:

因为使用了两个伪元素,当前单个按钮在 Hover 状态下的大致代码如下:


li {
    position: relative;
    width: 160px;
    height: 36px;
    border-radius: 10px 0 0 10px;
    background: #ddd;

    &::before,
    &::after {
        content: "";
        position: absolute;
        right: 0;
        border-radius: unset;
    }

    &::before {
        width: 20px;
        height: 20px;
        top: -20px;
        background: radial-gradient(circle at 0 0, transparent, transparent 19.5px, #ddd 20px, #ddd);
    }
    &::after {
        width: 20px;
        height: 20px;
        bottom: -20px;
        background: radial-gradient(circle at 0 100%, transparent, transparent 19.5px, #ddd 20px, #ddd);
    }
}

如果使用
box-shadow
肯定是不行的,整个效果就会露馅:

尝试给按钮添加一个
box-shadow: 0 0 5px 0 #333

弯曲的连接处,明显没有阴影效果,怎么解决呢?

嗯哼,老读者一定也知道,这里我们需要对整个可见部分添加阴影,需要使用
filter:drop-shadow()

drop-shadow()
滤镜的作用用于创建一个符合元素(图像)本身形状(alpha 通道)的阴影。其中,最为常见的技巧,就是利用它生成不规则图形的阴影。

因此,我们把上述的
box-shadow
替换成:
filter: drop-shadow(0 0 5px #ddd)

这样,我们就实现了基于单个不规则按钮的阴影效果。

但是,显然事情还没有结束。

修改布局结构,再借助利用 drop-shadow 实现统一阴影

记得我们上面提到过的 HTML 的布局吗?正常而言,右侧的主体内容和左侧的导航,结构是分离的:

<div class="g-container">
    <div class="g-nav">
        <ul>
            <li>Tab 1</li>
            // ...
        </ul>
    </div>
    <div class="g-main">
        <ul class="g-content">
            <li>...</li>
            // ...
        </ul>
    </div>
</div>

因此,这里最为麻烦的地方在于,
左侧按钮的阴影,需要和右侧的主体内容连在一起!
,所以当我们给右侧的
.g-main
也添加上相同的
filter:drop-shadow()
时,整个效果会变得非常奇怪:

// 当前被 Hover 的 li
.g-nav li {
    filter: drop-shadow(0 0 5px #ddd)
}
// 右侧的主体
.g-main {
    filter: drop-shadow(0 0 5px #ddd)
}

无论层级谁在上,整体阴影的展示都会瑕疵:

所以,如果想要实现整个元素的阴影是一整个的整体的效果,我们就不得不另辟蹊径。

这里,我们的思路如下:

  1. 可以尝试在
    .g-main
    中,添加一组与
    .g-nav
    相同的结构,负责样式层面的展示
  2. 把新增的结构,利用绝对定位,让其与实际的导航位置重叠
  3. 在原本的
    .g-nav
    中,通过
    :has()
    伪类,传递实时的 Hover 状态

基于此,我们需要改造一下我们的结构:

<div class="g-container">
    <div class="g-nav">
        <ul>
            <li>Tab 1</li>
            <li>Tab 2</li>
            <li>Tab 3</li>
            <li>Tab 4</li>
        </ul>
    </div>
    <div class="g-main">
        <ul class="g-status">
            <li></li>
            <li></li>
            <li></li>
            <li></li>
        </ul>
        <ul class="g-content">
            <li>...</li>
            // ...
        </ul>
    </div>
</div>

仔细看上面的结构,我们多了一组
.g-stauts
结构,放置在了
.g-main
之下。其 li 个数与实际的导航
.g-nav
保持一致,并且高宽大小都是一模一样的。

并且,可以利用绝对定位,让其完全叠加在
.g-nav
的位置上。

然后,我们把上述类 Chrome Tab 样式的不规则按钮结构的 CSS 代码结构,都赋给
.g-status
下的 li。

此时,由于不规则按钮结构和右侧的主体内容结构,其实是在一个父 div 之下,所以,我们只需要给
.g-main
元素添加
filter: drop-shadow()
,就可以实现一整个整体的阴影效果:

最后,我们利用
:has()
伪类,传递实时的 Hover 状态,把内外的结构连接起来。

最为核心的代码如下:

.g-main {
    background: #ddd;
    filter: drop-shadow(0 0 3px #999);
}
.g-status {
    position:absolute;
    left: -160px;
    top: 0;
    width: 160px;

    li {
        width: 160px;
        height: 36px;
        position: relative;
        background: #ddd;
        opacity:0;

        &::before,
        &::after {
            content: "";
            position: absolute;
            right: 0;
            border-radius: unset;
        }
        &::before {
            width: 20px;
            height: 20px;
            top: -20px;
            background: radial-gradient(circle at 0 0, transparent, transparent 19.5px, #ddd 20px, #ddd);
        }
        &::after {
            width: 20px;
            height: 20px;
            bottom: -20px;
            background: radial-gradient(circle at 0 100%, transparent, transparent 19.5px, #ddd 20px, #ddd);
        }
    }
}
.g-status li {
    opacity: 0;
}
.g-nav:has(li:nth-child(1):hover) + .g-main {
    .g-status li:nth-child(1) {
        opacity: 1;
    }
}
.g-nav:has(li:nth-child(2):hover) + .g-main {
    .g-status li:nth-child(2) {
        opacity: 1;
    }
}
.g-nav:has(li:nth-child(3):hover) + .g-main {
    .g-status li:nth-child(3) {
        opacity: 1;
    }
}
.g-nav:has(li:nth-child(4):hover) + .g-main {
    .g-status li:nth-child(4) {
        opacity: 1;
    }
}

什么意思呢?解释一下:

  1. 事先把每一个 Tab 被 Hover 时的样式,都写在了
    .g-stauts
    中,并且再提醒一下,这个结构是在
    .g-main
    之下的。然后,默认设置隐藏即可;
  2. 实际触发 Hover 动画效果,是正常的
    .g-nav
    下的一个一个的 li 结构;

  3. .g-nav
    下的一个一个的 li 被 Hover 时,我们通过
    :has()
    伪类,能够拿到此事件,并且根据当前是第几个元素被 hover,对应的控制实际在
    .g-main
    下的结构进行样式的展示;

不太了解的
:has()
伪类的小伙伴,可以先读一读这篇文章 --
浅谈逻辑选择器 is、where、not、has
,此伪类的诞生,填补了在之前 CSS 选择器中,没有父选择器的空缺。让我们能够在父元素节点上,根据子元素的状态变化,做出样式的调整。

这样,我们就最终实现了我们文章一开始的效果:

文章可能有部分内容没有阐述的很清晰,完整的代码其实行数非常之少,对文章内容还不太理解的建议戳进 DEMO 中看看。

完整的 DEMO 效果:
CodePen Demo -- Tab Hover Effect

有小伙伴会有疑问,为什么不直接在
.g-nav
导航结构和
.g-main
结构的父节点直接添加
drop-shadow()
,不是也可以实现一样的效果吗?

是的,对于本文贴出的代码效果,是可以实现一样的效果。但是,实际业务中,
.g-nav
会更复杂,它们的共同父元素下也可能还有其他元素,实际情况远比本文贴出来的结构复杂,因此借助多一层虚拟 ul,实际上是更好的解法。

最后


好了,本文到此结束,希望本文对你有所帮助

前言

最近有粉丝找到我,说被面试官给问懵了。

  • 粉丝:面试官上来就问“
    一个vue文件是如何渲染成浏览器上面的真实DOM?
    ”,当时还挺窃喜这题真简单。就简单说了一下先是编译成render函数、然后根据render函数生成虚拟DOM,最后就是根据虚拟DOM生成真实DOM。按照正常套路面试官接着会问vue响应式原理和diff算法,结果面试官不讲武德问了我“
    那render函数又是怎么生成的呢?
    ”。

  • 我:之前写过一篇
    看不懂来打我,vue3如何将template编译成render函数
    文章专门讲过这个吖。

  • 粉丝:我就是按照你文章回答的面试官,底层其实是调用的一个叫
    baseCompile
    的函数。在
    baseCompile
    函数中主要有三部分,执行
    baseParse
    函数将template模版转换成
    模版AST抽象语法树
    ,接着执行
    transform
    函数处理掉vue内置的指令和语法糖就可以得到
    javascript AST抽象语法树
    ,最后就是执行
    generate
    函数递归遍历
    javascript AST抽象语法树
    进行字符串拼接就可以生成render函数。当时在想这回算是稳了,结果跟着就翻车了。

  • 粉丝:面试官接着又让我讲“
    transform
    函数内具体是如何处理vue内置的v-for、v-model等指令?

    ”,你的文章中没有具体讲过这个吖,我只有说不知道。面试官接着又问:
    generate
    函数是如何进行字符串拼接得到的render函数呢?

    ,我还是回答的不知道。

  • 我:我的锅,接下来就先安排一篇文章来讲讲
    transform
    函数内具体是如何处理vue内置的v-for、v-model等指令?

先来看个流程图

先来看一下我画的
transform
函数执行流程图,让你对整个流程有个大概的印象,后面的内容看着就不费劲了。如下图:
full-progress

从上面的流程图可以看到
transform
函数的执行过程主要分为下面这几步:


  • transform
    函数中调用
    createTransformContext
    函数生成上下文对象。在上下文对象中存储了当前正在转换的node节点的信息,后面的
    traverseNode

    traverseChildren

    nodeTransforms
    数组中的转换函数、
    directiveTransforms
    对象中的转换函数都会依赖这个上下文对象。

  • 然后执行
    traverseNode
    函数,
    traverseNode
    函数是一个典型的洋葱模型。第一次执行
    traverseNode
    函数的时候会进入洋葱模型的第一层,先将
    nodeTransforms
    数组中的转换函数全部执行一遍,对第一层的node节点进行第一次转换,将转换函数返回的回调函数存到第一层的
    exitFns
    数组中。经过第一次转换后v-for等指令已经被初次处理了。

  • 然后执行
    traverseChildren
    函数,在
    traverseChildren
    函数中对当前node节点的子节点执行
    traverseNode
    函数。此时就会进入洋葱模型的第二层,和上一步一样会将
    nodeTransforms
    数组中的转换函数全部执行一遍,对第二层的node节点进行第一次转换,将转换函数返回的回调函数存到第二层的
    exitFns
    数组中。

  • 假如第二层的node节点已经没有了子节点,洋葱模型就会从“进入阶段”变成“出去阶段”。将第二层的
    exitFns
    数组中存的回调函数全部执行一遍,对node节点进行第二次转换,然后出去到第一层的洋葱模型。经过第二次转换后v-for等指令已经被完全处理了。

  • 同样将第一层中的
    exitFns
    数组中存的回调函数全部执行一遍,由于此时第二层的node节点已经全部处理完了,所以在
    exitFns
    数组中存的回调函数中就可以根据子节点的情况来处理父节点。

  • 执行
    nodeTransforms
    数组中的
    transformElement
    转换函数,会返回一个回调函数。在回调函数中会调用
    buildProps
    函数,在
    buildProps
    函数中只有当node节点中有对应的指令才会执行
    directiveTransforms
    对象中对应的转换函数。比如当前node节点有v-model指令,才会去执行
    transformModel
    转换函数。v-model等指令也就被处理了。

举个例子

还是同样的套路,我们通过debug一个简单的demo来带你搞清楚
transform
函数内具体是如何处理vue内置的v-for、v-model等指令。demo代码如下:

<template>
  <div>
    <input v-for="item in msgList" :key="item.id" v-model="item.value" />
    <p>标题是:{{ title }}</p>
  </div>
</template>

<script setup lang="ts">
import { ref } from "vue";

const msgList = ref([
  {
    id: 1,
    value: "",
  },
  {
    id: 2,
    value: "",
  },
  {
    id: 3,
    value: "",
  },
]);
const title = ref("hello word");
</script>

在上面的代码中,我们给input标签使用了v-for和v-model指令,还渲染了一个p标签。p标签中的内容由
foo
变量、
bar
字符串、
baz
变量拼接而来的。

我们在上一篇
看不懂来打我,vue3如何将template编译成render函数
文章中已经讲过了,将template模版编译成
模版AST抽象语法树
的过程中不会处理v-for、v-model等内置指令,而是将其当做普通的props属性处理。

比如我们这个demo,编译成
模版AST抽象语法树
后。input标签对应的node节点中就增加了三个props属性,name分别为for、bind、model,分别对应的是v-for、v-bind、v-model。真正处理这些vue内置指令是在
transform
函数中。

transform
函数

本文中使用的vue版本为
3.4.19

transform
函数在
node_modules/@vue/compiler-core/dist/compiler-core.cjs.js
文件中。找到
transform
函数的代码,打上断点。

从上一篇文章我们知道了
transform
函数是在node端执行的,所以我们需要启动一个
debug
终端,才可以在node端打断点。这里以vscode举例,首先我们需要打开终端,然后点击终端中的
+
号旁边的下拉箭头,在下拉中点击
Javascript Debug Terminal
就可以启动一个
debug
终端。
debug-terminal

接着在
debug
终端中执行
yarn dev
(这里是以
vite
举例)。在浏览器中访问
http://localhost:5173/
,此时断点就会走到
transform
函数中了。我们在debug终端中来看看调用
transform
函数时传入的
root
变量,如下图:
before-transform

从上图中我们可以看到
transform
函数接收的第一个参数
root
变量是一个
模版AST抽象语法树
,为什么说他是
模版AST抽象语法树
呢?因为这棵树的结构和template模块中的结构一模一样,
root
变量也就是
模版AST抽象语法树
是对template模块进行描述。

根节点的children下面只有一个div子节点,对应的就是最外层的div标签。div节点children下面有两个子节点,分别对应的是input标签和p标签。input标签中有三个props,分别对应input标签上面的v-for指令、key属性、v-model指令。从这里我们可以看出来此时vue内置的指令还没被处理,在执行parse函数生成
模版AST抽象语法树
阶段只是将其当做普通的属性处理后,再塞到props属性中。

p标签中的内容由两部分组成:
<p>标题是:{{ title }}</p>
。此时我们发现p标签的children也是有两个,分别是写死的文本和
title
变量。

我们接着来看
transform
函数,在我们这个场景中简化后的代码如下:

function transform(root, options) {
  const context = createTransformContext(root, options);
  traverseNode(root, context);
}

从上面的代码中可以看到
transform
函数内主要有两部分,从名字我想你应该就能猜出他们的作用。传入
模版AST抽象语法树

options
,调用
createTransformContext
函数生成
context
上下文对象。传入
模版AST抽象语法树

context
上下文对象,调用
traverseNode
函数对树中的node节点进行转换。

createTransformContext
函数

在讲
createTransformContext
函数之前我们先来了解一下什么是
context(上下文)

什么是上下文

上下文其实就是在某个范围内的“全局变量”,在这个范围内的任意地方都可以拿到这个“全局变量”。举两个例子:

在vue中可以通过provied向整颗组件树提供数据,然后在树的任意节点可以通过inject拿到提供的数据。比如:

根组件App.vue,注入上下文。

const count = ref(0)
provide('count', count)

业务组件list.vue,读取上下文。

const count = inject('count')

在react中,我们可以使用
React.createContext
函数创建一个上下文对象,然后注入到组件树中。

const ThemeContext = React.createContext('light');

function App() {
  const [theme, setTheme] = useState('light');
  // ...
  return (
    <ThemeContext.Provider value={theme}>
      <Page />
    </ThemeContext.Provider>
  );
}

在这颗组件树的任意层级中都能拿到上下文对象中提供的数据:

const theme = useContext(ThemeContext);

树中的节点一般可以通过children拿到子节点,但是父节点一般不容易通过子节点拿到。在转换的过程中我们有的时候需要拿到父节点进行一些操作,比如将当前节点替换为一个新的节点,又或者直接删掉当前节点。

所以在这里会维护一个context上下文对象,对象中会维护一些状态和方法。比如当前正在转换的节点是哪个,当前转换的节点的父节点是哪个,当前节点在父节点中是第几个子节点,还有
replaceNode

removeNode
等方法。

上下文中的一些属性和方法

我们将断点走进
createTransformContext
函数中,简化后的代码如下:

function createTransformContext(
  root,
  {
    nodeTransforms = [],
    directiveTransforms = {},
    // ...省略
  }
) {
  const context = {
    // 所有的node节点都会将nodeTransforms数组中的所有的转换函数全部执行一遍
    nodeTransforms,
    // 只执行node节点的指令在directiveTransforms对象中对应的转换函数
    directiveTransforms,
    // 需要转换的AST抽象语法树
    root,
    // 转换过程中组件内注册的组件
    components: new Set(),
    // 转换过程中组件内注册的指令
    directives: new Set(),
    // 当前正在转换节点的父节点,默认转换的是根节点。根节点没有父节点,所以为null。
    parent: null,
    // 当前正在转换的节点,默认为根节点
    currentNode: root,
    // 当前转换节点在父节点中的index位置
    childIndex: 0,
    replaceNode(node) {
      // 将当前节点替换为新节点
    },
    removeNode(node) {
      // 删除当前节点
    },
    // ...省略
  };
  return context;
}

从上面的代码中可以看到
createTransformContext
中的代码其实很简单,第一个参数为需要转换的
模版AST抽象语法树
,第二个参数对传入的
options
进行解构,拿到
options.nodeTransforms
数组和
options.directiveTransforms
对象。

nodeTransforms
数组中存了一堆转换函数,在树的递归遍历过程中会将
nodeTransforms
数组中的转换函数全部执行一遍。
directiveTransforms
对象中也存了一堆转换函数,和
nodeTransforms
数组的区别是,只会执行node节点的指令在
directiveTransforms
对象中对应的转换函数。比如node节点中只有v-model指令,那就只会执行
directiveTransforms
对象中的
transformModel
转换函数。这里将拿到的
nodeTransforms
数组和
directiveTransforms
对象都存到了
context
上下文中。


context
上下文中存了一些状态属性:

  • root:需要转换的AST抽象语法树。

  • components:转换过程中组件内注册的组件。

  • directives:转换过程中组件内注册的指令。

  • parent:当前正在转换节点的父节点,默认转换的是根节点。根节点没有父节点,所以为null。

  • currentNode:当前正在转换的节点,默认为根节点。

  • childIndex:当前转换节点在父节点中的index位置。


context
上下文中存了一些方法:

  • replaceNode:将当前节点替换为新节点。

  • removeNode:删除当前节点。

traverseNode
函数

接着将断点走进
traverseNode
函数中,在我们这个场景中简化后的代码如下:

function traverseNode(node, context) {
  context.currentNode = node;
  const { nodeTransforms } = context;
  const exitFns = [];
  for (let i = 0; i < nodeTransforms.length; i++) {
    const onExit = nodeTransforms[i](node, context);
    if (onExit) {
      if (isArray(onExit)) {
        exitFns.push(...onExit);
      } else {
        exitFns.push(onExit);
      }
    }
    if (!context.currentNode) {
      return;
    } else {
      node = context.currentNode;
    }
  }

  traverseChildren(node, context);

  context.currentNode = node;
  let i = exitFns.length;
  while (i--) {
    exitFns[i]();
  }
}

从上面的代码中我们可以看到
traverseNode
函数接收两个参数,第一个参数为当前需要处理的node节点,第一次调用时传的就是树的根节点。第二个参数是上下文对象。

我们再来看
traverseNode
函数的内容,内容主要分为三部分。分别是:


  • nodeTransforms
    数组内的转换函数全部执行一遍,如果转换函数的执行结果是一个回调函数,那么就将回调函数push到
    exitFns
    数组中。

  • 调用
    traverseChildren
    函数处理子节点。


  • exitFns
    数组中存的回调函数依次从末尾取出来挨个执行。

traverseChildren
函数

我们先来看看第二部分的
traverseChildren
函数,代码很简单,简化后的代码如下:

function traverseChildren(parent, context) {
  let i = 0;
  for (; i < parent.children.length; i++) {
    const child = parent.children[i];
    context.parent = parent;
    context.childIndex = i;
    traverseNode(child, context);
  }
}


traverseChildren
函数中会去遍历当前节点的子节点,在遍历过程中会将
context.parent
更新为当前的节点,并且将
context.childIndex
也更新为当前子节点所在的位置。然后再调用
traverseNode
函数处理当前的子节点。

所以在
traverseNode
函数执行的过程中,
context.parent
总是指向当前节点的父节点,
context.childIndex
总是指向当前节点在父节点中的index位置。如下图:

traverseChildren

进入时执行的转换函数

我们现在回过头来看第一部分的代码,代码如下:

function traverseNode(node, context) {
  context.currentNode = node;
  const { nodeTransforms } = context;
  const exitFns = [];
  for (let i = 0; i < nodeTransforms.length; i++) {
    const onExit = nodeTransforms[i](node, context);
    if (onExit) {
      if (isArray(onExit)) {
        exitFns.push(...onExit);
      } else {
        exitFns.push(onExit);
      }
    }
    if (!context.currentNode) {
      return;
    } else {
      node = context.currentNode;
    }
  }
  // ...省略
}

首先会将
context.currentNode
更新为当前节点,然后从context上下文中拿到由转换函数组成的
nodeTransforms
数组。


看不懂来打我,vue3如何将template编译成render函数
文章中我们已经讲过了
nodeTransforms
数组中主要存了下面这些转换函数,代码如下:

const nodeTransforms = [
  transformOnce,
  transformIf,
  transformMemo,
  transformFor,
  transformFilter,
  trackVForSlotScopes,
  transformExpression
  transformSlotOutlet,
  transformElement,
  trackSlotScopes,
  transformText
]

很明显我们这里的v-for指令就会被
nodeTransforms
数组中的
transformFor
转换函数处理。

看到这里有的小伙伴就会问了,怎么没有在
nodeTransforms
数组中看到处理
v-model
指令的转换函数呢?处理
v-model
指令的转换函数是在
directiveTransforms
对象中。在
directiveTransforms
对象中主要存了下面这些转换函数:

const directiveTransforms = {
  bind: transformBind,
  cloak: compilerCore.noopDirectiveTransform,
  html: transformVHtml,
  text: transformVText,
  model: transformModel,
  on: transformOn,
  show: transformShow
}

nodeTransforms

directiveTransforms
的区别是,在递归遍历转换node节点时,每次都会将
nodeTransforms
数组中的所有转换函数都全部执行一遍。比如当前转换的node节点中没有使用v-if指令,但是在转换当前node节点时还是会执行
nodeTransforms
数组中的
transformIf
转换函数。


directiveTransforms
是在递归遍历转换node节点时,只会执行node节点中存在的指令对应的转换函数。比如当前转换的node节点中有使用v-model指令,所以就会执行
directiveTransforms
对象中的
transformModel
转换函数。由于node节点中没有使用v-html指令,所以就不会执行
directiveTransforms
对象中的
transformVHtml
转换函数。

我们前面讲过了context上下文中存了很多属性和方法。包括当前节点的父节点是谁,当前节点在父节点中的index位置,替换当前节点的方法,删除当前节点的方法。这样在转换函数中就可以通过context上下文对当前节点进行各种操作了。

将转换函数的返回值赋值给
onExit
变量,如果
onExit
不为空,说明转换函数的返回值是一个回调函数或者由回调函数组成的数组。将这些回调函数push进
exitFns
数组中,在退出时会将这些回调函数倒序全部执行一遍。

执行完回调函数后会判断上下文中的
currentNode
是否为空,如果为空那么就return掉整个
traverseNode
函数,后面的
traverseChildren
等函数都不会执行了。如果
context.currentNode
不为空,那么就将本地的
node
变量更新成context上下文中的
currentNode

为什么需要判断context上下文中的
currentNode
呢?原因是经过转换函数的处理后当前节点可能会被删除了,也有可能会被替换成一个新的节点,所以在每次执行完转换函数后都会更新本地的node变量,保证在下一个的转换函数执行时传入的是最新的node节点。

退出时执行的转换函数回调

我们接着来看
traverseNode
函数中最后一部分,代码如下:

function traverseNode(node, context) {
  // ...省略
  context.currentNode = node;
  let i = exitFns.length;
  while (i--) {
    exitFns[i]();
  }
}

由于这段代码是在执行完
traverseChildren
函数再执行的,前面已经讲过了在
traverseChildren
函数中会将当前节点的子节点全部都处理了,所以当代码执行到这里时所有的子节点都已经处理完了。
所以在转换函数返回的回调函数中我们可以根据当前节点转换后的子节点情况来决定如何处理当前节点。

在处理子节点的时候我们会将
context.currentNode
更新为子节点,所以在处理完子节点后需要将
context.currentNode
更新为当前节点。这样在执行转换函数返回的回调函数时,
context.currentNode
始终就是指向的是当前的node节点。

请注意这里是倒序取出
exitFns
数组中存的回调函数,在进入时会按照顺序去执行
nodeTransforms
数组中的转换函数。在退出时会倒序去执行存下来的回调函数,比如在
nodeTransforms
数组中
transformIf
函数排在
transformFor
函数前面。
transformIf
用于处理v-if指令,
transformFor
用于处理v-for指令。在进入时
transformIf
函数会比
transformFor
函数先执行,所以在组件上面同时使用v-if和v-for指令,会是v-if指令先生效。在退出阶段时
transformIf
函数会比
transformFor
函数后执行,所以在
transformIf
回调函数中可以根据
transformFor
回调函数的执行结果来决定如何处理当前的node节点。

traverseNode
函数其实就是典型的
洋葱模型
,依次从父组件到子组件挨着调用
nodeTransforms
数组中所有的转换函数,然后从子组件到父组件倒序执行
nodeTransforms
数组中所有的转换函数返回的回调函数。
traverseNode
函数内的设计很高明,如果你还没反应过来,别着急我接下来会讲他高明在哪里。

洋葱模型
traverseNode
函数

我们先来看看什么是洋葱模型,如下图:
onion

洋葱模型就是:从外面一层层的进去,再一层层的从里面出来。

第一次进入
traverseNode
函数的时候会进入洋葱模型的第1层,先依次将
nodeTransforms
数组中所有的转换函数全部执行一遍,对当前的node节点进行第一次转换。如果转换函数的返回值是回调函数或者回调函数组成的数组,那就将这些回调函数依次push到第1层定义的
exitFns
数组中。

然后再去处理当前节点的子节点,处理子节点的
traverseChildren
函数其实也是在调用
traverseNode
函数,此时已经进入了洋葱模型的第2层。同理在第2层也会将
nodeTransforms
数组中所有的转换函数全部执行一遍,对第2层的node节点进行第一次转换,并且将返回的回调函数依次push到第2层定义的
exitFns
数组中。

同样的如果第2层节点也有子节点,那么就会进入洋葱模型的第3层。在第3层也会将
nodeTransforms
数组中所有的转换函数全部执行一遍,对第3层的node节点进行第一次转换,并且将返回的回调函数依次push到第3层定义的
exitFns
数组中。

请注意此时的第3层已经没有子节点了,那么现在就要从一层层的进去,变成一层层的出去。首先会将第3层
exitFns
数组中存的回调函数依次从末尾开始全部执行一遍,会对第3层的node节点进行第二次转换,此时第3层中的node节点已经被全部转换完了。

由于第3层的node节点已经被全部转换完了,所以会出去到洋葱模型的第2层。同样将第2层
exitFns
数组中存的回调函数依次从末尾开始全部执行一遍,会对第2层的node节点进行第二次转换。值得一提的是由于第3层的node节点也就是第2层的children节点已经被完全转换了,所以在执行第2层转换函数返回的回调函数时就可以根据子节点的情况来处理父节点。

同理将第2层的node节点全部转换完了后,会出去到洋葱模型的第1层。将第1层
exitFns
数组中存的回调函数依次从末尾开始全部执行一遍,会对第1层的node节点进行第二次转换。

当出去阶段的第1层全部处理完后了,
transform
函数内处理内置的v-for等指令也就处理完了。执行完
transform
函数后,描述template解构的
模版AST抽象语法树
也被处理成了描述render函数结构的
javascript AST抽象语法树
。后续只需要执行
generate
函数,进行普通的字符串拼接就可以得到render函数。

继续debug

搞清楚了
traverseNode
函数,接着来debug看看demo中的v-for指令和v-model指令是如何被处理的。

  • v-for指令对应的是
    transformFor
    转换函数。

  • v-model指令对应的是
    transformModel
    转换函数。

transformFor
转换函数

通过前面我们知道了用于处理
v-for
指令的
transformFor
转换函数是在
nodeTransforms
数组中,每次处理node节点都会执行。我们给
transformFor
转换函数打3个断点,分别是:

  • 进入
    transformFor
    转换函数之前。

  • 调用
    transformFor
    转换函数,第1次对node节点进行转换之后。

  • 调用
    transformFor
    转换函数返回的回调函数,第2次对node节点进行转换之后。

我们将代码走到第1个断点,看看执行
transformFor
转换函数之前input标签的node节点是什么样的,如下图:
transformFor1

从上图中可以看到input标签的node节点中还是有一个v-for的props属性,说明此时v-for指令还没被处理。

我们接着将代码走到第2个断点,看看调用
transformFor
转换函数第1次对node节点进行转换之后是什么样的,如下图:
transformFor2

从上图中可以看到原本的input的node节点已经被替换成了一个新的node节点,新的node节点的children才是原来的node节点。并且input节点props属性中的v-for指令也被消费了。新节点的
source.content
里存的是
v-for="item in msgList"
中的
msgList
变量。新节点的
valueAlias.content
里存的是
v-for="item in msgList"
中的
item
。请注意此时
arguments
数组中只有一个字段,存的是
msgList
变量。

我们接着将代码走到第3个断点,看看调用
transformFor
转换函数返回的回调函数,第2次对node节点进行转换之后是什么样的,如下图:
transformFor3

从上图可以看到
arguments
数组中多了一个字段,input标签现在是当前节点的子节点。按照我们前面讲的洋葱模型,input子节点现在已经被转换完成了。所以多的这个字段就是input标签经过
transform
函数转换后的node节点,将转换后的input子节点存到父节点上面,后面生成render函数时会用。

transformModel
转换函数

通过前面我们知道了用于处理
v-model
指令的
transformModel
转换函数是在
directiveTransforms
对象中,只有当node节点中有对应的指令才会执行对应的转换函数。我们这里input上面有v-model指令,所以就会执行
transformModel
转换函数。

我们在前面的
看不懂来打我,vue3如何将template编译成render函数
文章中已经讲过了处理
v-model
指令是调用的
@vue/compiler-dom
包的
transformModel
函数,很容易就可以找到
@vue/compiler-dom
包的
transformModel
函数,然后打一个断点,让断点走进
transformModel
函数中,如下图:
transformModel

从上面的图中我们可以看到在
@vue/compiler-dom
包的
transformModel
函数中会调用
@vue/compiler-core
包的
transformModel
函数,拿到返回的
baseResult
对象后再一些其他操作后直接
return baseResult

从左边的call stack调用栈中我们可以看到
transformModel
函数是由一个
buildProps
函数调用的,
buildProps
函数是由
postTransformElement
函数调用的。而
postTransformElement
函数则是
transformElement
转换函数返回的回调函数,
transformElement
转换函数是在
nodeTransforms
数组中。

所以
directiveTransforms
对象中的转换函数调用其实是由
nodeTransforms
数组中的
transformElement
转换函数调用的。如下图:
directiveTransforms

看名字你应该猜到了
buildProps
函数的作用是生成props属性的。点击Step Out将断点跳出
transformModel
函数,走进
buildProps
函数中,可以看到
buildProps
函数中调用
transformModel
函数的代码如下图:
buildProps

从上图中可以看到执行
directiveTransforms
对象中的转换函数不仅可以对节点进行转换,还会返回一个props数组。比如我们这里处理的是v-model指令,返回的props数组就是由v-model指令编译而来的props属性,这就是所谓的v-model语法糖。

看到这里有的小伙伴会疑惑了
v-model
指令不是会生成
modelValue

onUpdate:modelValue
两个属性,为什么这里只有一个
onUpdate:modelValue
属性呢?

答案是只有给自定义组件上面使用
v-model
指令才会生成
modelValue

onUpdate:modelValue
两个属性,对于这种原生input标签是不需要生成
modelValue
属性的,而且input标签本身是不接收名为
modelValue
属性,接收的是value属性。

总结

现在我们再来看看最开始讲的流程图,我想你应该已经能将整个流程串起来了。如下图:
full-progress

transform
函数的执行过程主要分为下面这几步:


  • transform
    函数中调用
    createTransformContext
    函数生成上下文对象。在上下文对象中存储了当前正在转换的node节点的信息,后面的
    traverseNode

    traverseChildren

    nodeTransforms
    数组中的转换函数、
    directiveTransforms
    对象中的转换函数都会依赖这个上下文对象。

  • 然后执行
    traverseNode
    函数,
    traverseNode
    函数是一个典型的洋葱模型。第一次执行
    traverseNode
    函数的时候会进入洋葱模型的第一层,先将
    nodeTransforms
    数组中的转换函数全部执行一遍,对第一层的node节点进行第一次转换,将转换函数返回的回调函数存到第一层的
    exitFns
    数组中。经过第一次转换后v-for等指令已经被初次处理了。

  • 然后执行
    traverseChildren
    函数,在
    traverseChildren
    函数中对当前node节点的子节点执行
    traverseNode
    函数。此时就会进入洋葱模型的第二层,和上一步一样会将
    nodeTransforms
    数组中的转换函数全部执行一遍,对第二层的node节点进行第一次转换,将转换函数返回的回调函数存到第二层的
    exitFns
    数组中。

  • 假如第二层的node节点已经没有了子节点,洋葱模型就会从“进入阶段”变成“出去阶段”。将第二层的
    exitFns
    数组中存的回调函数全部执行一遍,对node节点进行第二次转换,然后出去到第一层的洋葱模型。经过第二次转换后v-for等指令已经被完全处理了。

  • 同样将第一层中的
    exitFns
    数组中存的回调函数全部执行一遍,由于此时第二层的node节点已经全部处理完了,所以在
    exitFns
    数组中存的回调函数中就可以根据子节点的情况来处理父节点。

  • 执行
    nodeTransforms
    数组中的
    transformElement
    转换函数,会返回一个回调函数。在回调函数中会调用
    buildProps
    函数,在
    buildProps
    函数中只有当node节点中有对应的指令才会执行
    directiveTransforms
    对象中对应的转换函数。比如当前node节点有v-model指令,才会去执行
    transformModel
    转换函数。v-model等指令也就被处理了。

关注公众号:
前端欧阳
,解锁我更多
vue
干货文章。还可以加我微信,私信我想看哪些
vue
原理文章,我会根据大家的反馈进行创作。
qrcode

接下来我将持续更新“深度解读《深度探索C++对象模型》”系列,敬请期待,欢迎关注!也可以关注公众号:iShare爱分享,自动获得推文和全部的文章列表。

C++11新标准中最重要的特性之一就是引入了支持对象移动的能力,为了支持移动的操作,新标准引入了一种新的引用类型——右值引用,右值引用一个重要的性质就是只能绑定到一个将要销毁的对象。对对象执行移动操作后要确保源对象处于可析构的状态,源对象随时可能被销毁,所以程序在之后不要再去使用源对象的值,同时也要保证源对象析构之后不会对移入对象产生副作用。移动语义的加持使得移动一个如容器之类的大对象的成本可以像复制一个指针一样低廉了,于是出现了各种各样的传言:如编译器会使用移动操作来替代拷贝操作以获得效率上的提升,甚至说将符合C++98标准的以前的老代码用符合C++11新标准的编译器重新编译一次,一行代码未改即可获得运行速度上质的提升。对于种种传闻,事实上是否如此?接下来让我们拨开层层迷雾,来一探究竟,看完这篇文章,你的心中就会有答案。

为了支持对象的移动,新标准新增了移动构造函数和移动赋值运算符,移动构造函数和移动赋值运算符的情形类似,所以放在一起讨论。对于传闻中如果程序中没有定义移动构造函数,那么编译器就会帮助程序生成一个移动构造函数这一说法是否可靠?我们以实际的代码来分析一下,由于移动构造函数需要一个右值引用作为第一个参数,测试代码中可以使用标准库里的move函数来产生一个右值引用,move函数其实就是一个类型转换,它可以把一个左值转换成右值引用。看看下面的代码是否编译器会合成出来移动构造函数:

#include <utility>

class Object {
    int a;
};

int main() {
    Object d;
    Object d1 = std::move(d);
    
    return 0;
}

把它编译成汇编代码看一下:

main:						# @main
    push    rbp
    mov     rbp, rsp
    mov     dword ptr [rbp - 4], 0
    mov     eax, dword ptr [rbp - 8]
    mov     dword ptr [rbp - 16], eax
    xor     eax, eax
    pop     rbp
    ret

实际上编译器并没有生成一个移动构造函数,甚至任何构造函数都没有生成。因为没有必要,在这种情况下,编译器可以做一些优化,执行按对象的成员逐个复制过去就可以了,不需要生成一个函数来做这个事情。上面汇编代码的第5、第6行就是将对象d(存放在栈空间[rbp - 8]中)的内容先拷贝到eax寄存器,然后再从寄存器eax拷贝到对象d1(存放在栈空间[rbp - 16]中)。

那么在什么情况下才会合成出来移动构造函数呢?

编译器合成移动构造函数的条件

编译器只有在以下的这些情况下才会合成出来移动构造函数:

  1. 类中没有定义拷贝构造函数、拷贝赋值运算符、析构函数;且:
  2. 类的定义中有一个类类型的成员,这个类成员定义了移动构造函数;或者:
  3. 继承的父类中定义了移动构造函数;或者:
  4. 类中定义了或者从父类中继承了一个以上的虚函数;或者:
  5. 类的继承链上有一个父类是virtual base class。

在上面C++代码的Object类中增加一个std::string类型的成员,std::string是标准库中提供的操作字符串的类,类中有定义了移动构造函数。Object类定义如下:

class Object {
    std::string s;
    int a;
};

把它编译成汇编代码,可以看到这下汇编代码变得很多,不光生成了Object类的移动构造函数,还有默认构造函数和析构函数。main函数的汇编代码如下:

main:							# @main
    push    rbp
    mov     rbp, rsp
    sub     rsp, 96
    mov     dword ptr [rbp - 4], 0
    lea     rdi, [rbp - 48]
    call    Object::Object() [base object constructor]
    lea     rdi, [rbp - 88]
    lea     rsi, [rbp - 48]
    call    Object::Object(Object&&) [base object constructor]
    mov     dword ptr [rbp - 4], 0
    lea     rdi, [rbp - 88]
    call    Object::~Object() [base object destructor]
    lea     rdi, [rbp - 48]
    call    Object::~Object() [base object destructor]
    mov     eax, dword ptr [rbp - 4]
    add     rsp, 96
    pop     rbp
    ret

上面汇编代码的第7行调用了Object类的默认构造函数,因为string类里也定义了默认构造函数,所以这里需要去调用它,具体分析可见另外一篇的分析文章。第10行实际上就是调用Object类的移动构造函数了,在Object类的移动构造函数里会去调用string类的移动构造函数。所以可以推测出来,只有需要调用类类型成员的移动构造函数的时候编译器才会合成一个移动构造函数出来,在合成的移动构造函数中去调用它,上面的第3种情况也类似,第4和第5种情形是因为编译器需要重设虚表指针,所以也会生成一个移动构造函数来完成,这些情形跟合成拷贝构造函数的机制是类似的,具体的分析可以见《编译器背后的行为之拷贝构造函数》这篇文章,这里就不再一一赘述了。

编译器抑制合成移动构造函数的情形

虽然说合成移动构造函数的时机和合成拷贝构造函数的类似,但是合成移动构造函数的条件要比合成拷贝构造函数要苛刻得多,在以下的情形中,移动构造函数的合成将受到抑制,编译器不会合成一个移动构造函数出来。

  • 类中只要定义了拷贝构造函数、拷贝赋值运算符和析构函数的其中一个,编译器就不会合成移动构造函数

有这么一个指导原则,叫做Rule of Three,大意是:主要你定义了拷贝构造函数、拷贝赋值运算符、析构函数中的一个,你就必须要全部定义它们。原因就是既然你需要自己实现拷贝的操作,说明这里需要管理资源,比如内存的申请和释放,在拷贝构造函数里需要管理资源,意味着在拷贝赋值运算符函数里也需要,反之亦然,同时也需要在析构函数中释放资源。由此可以得出的推论就是如果你定义了这其中的一个函数,说明有资源需要特别处理,那么编译器合成出来的移动构造函数可能就不是你想要的效果,甚至破坏程序的逻辑,引起潜在的bug,所以编译器就不会合成出来移动构造函数。

按照上面的推论,如果定义了析构函数,那么编译器就不应该生成拷贝构造函数和拷贝赋值运算符了,但是C++98标准中却留下了一个“bug“:在定义了析构函数之后,编译器还是会在有需要的时候合成出拷贝构造函数和拷贝赋值运算符,C++11标准为了兼容C++98,同样地也允许合成出来,但是对于移动构造函数和移动赋值运算符,
C++11标准中明确规定了:只要定义了析构函数,编译器便不再合成出移动构造函数和移动赋值运算符。

如果你的代码中没有定义上面的三种函数,你的类中的成员也是可以移动的,编译器在这时也为程序合成出了移动构造函数或者移动赋值运算符,如果这一切正符合你的本意,那么这种情况下建议你,最好在你的代码中把移动构造函数或移动赋值运算符用=default显示地声明出来。原因在于,假如有一个类,类中有一个容器,容器存放了大量的数据,类中没有定义拷贝构造函数和析构函数等,编译器也合成了移动构造函数,使得对象的移动非常高效。但是突然有天来个需求,需要在对象的构造和析构时记录下来,于是你增加了构造函数和析构函数以满足需求,但是加入代码重新编译之后发现程序执行的效率变差了,甚至有可能差了几个数量级,
根源在于你定义了析构函数之后,编译器便不再合成移动构造函数了,而是用拷贝操作替换了移动的操作
,所以显示地声明它们是一种好的习惯,尽管我们不需要实现这个函数的代码,所以使用
=default
让编译器来自动生成。

  • 如果类的定义中有一个类类型的成员或者继承自一个父类,这个类成员或者父类里的移动构造函数或者移动赋值运算符被定义为删除的(=delete)或者是不可访问的(定义为private),那么此类的移动构造函数或者移动赋值运算符被定义为删除的。

如下面的例子:

#include <utility>
#include <string>

class Base {
public:
    Base() = default;
    Base(Base&& rhs) = delete;
    int b;
};

class Object {
public:
    Base b;
    std::string s;
    int a;
};

int main() {
    Object d;
    Object d1 = std::move(d);	// 这行编译不通过。
    
    return 0;
}

上面的例子中,编译器不再会生成移动构造函数和拷贝构造函数,所以第20行的代码将编译不通过,因为没有拷贝构造函数或移动构造函数供调用。

  • 如果类的析构函数被定义为删除的或不可访问的,那么此类的移动构造函数被定义为删除的。

移动操作并未使效率更高的情况

在某些情况下,移动构造函数或移动赋值运算符被正确地合成出来或者由程序员定义出来了,但是程序却并未如预期的提升运行效率,如以下的场景:

  • 没有移动操作

假如类中有了移动构造函数(合成的或者用户定义的),同时类中有一个类类型的成员,这个成员刚好存放着大量数据,而此成员的类定义中没有定义移动构造函数,因此它只可以拷贝而不能移动。当对对象实施move操作时,实际上将会对对象的每个成员依次递归地实施move调用,它将匹配适合这个成员的操作,即如果成员是可移动则执行移动操作,如果不可移动的则执行拷贝操作。所以实际上将会调用此成员的拷贝构造函数。

另一种情形,如std::array容器,它是C++11标准新提供的容器类型,功能相当于内建的数组,它不同于别的容器类型将数据存储在堆中,然后使用指针指向数据,移动容器只需赋值指针,然后将源指针置空即可。array容器的数据是存放在对象上,即使数组里存放的元素类型能提供移动操作,那也得需要一个个地将每个元素执行一遍移动操作,这个时间是一个线性时间复杂度。

  • 移动的效率不高

std::string类往往采用了小型字符串优化(small string optimization, SSO)的实现手法,SSO是将小型字符串(比如长度小于15个字符)直接存储在string对象内的缓冲区中,超过这个长度的则存放在堆上。之所以采用SSO优化手法,就是因为在实际应用场景中大多数使用的字符串长度都比较短,这样可避免频繁地申请和释放内存带来的开销。在使用了SSO的情况下,移动一个string对象并不比较拷贝来得更快,实际上这种情况移动操作执行的是拷贝动作。

  • 移动操作未被调用

即使类中提供的移动操作比拷贝操作的效率明显要高得多,但是也有可能未能调用到移动操作,依然使用的是拷贝操作,导致实际效果效率不高的问题。比如标准库中的vector容器,它提供了一个push_back的接口,调用此接口向容器中加入一个元素,这时有可能容器的容量满了,需要申请一块更大的内存,然后把原先内存位置的元素搬过去再销毁掉。vector容器的实现者需要保证这个过程的前后状态要保持不变,在移动元素时,如果元素的类型提供了移动功能,那么vector容器就会使用它,但是要求这个移动操作必须是noexcept的,假如移动操作不能保证是noexcept的,vector容器就不会使用它。

试想一下,假如在移动到一半的时候,这时抛出了异常,移动操作随即停止,这时一半的元素在新空间中,一半的元素在旧的空间中,vector无法恢复到原先的状态。拷贝操作则不会存在这个问题,假如在拷贝过程中出现问题,那么只需要将新空间的元素和新申请的内存释放掉,vector的状态还是保持不变。

所以如果你的类型中的移动构造函数未加上noexcept声明,即使类型中的移动操作比对应的拷贝操作的效率要高效得多,编译器仍会强制去调用拷贝操作而非移动操作。因此建议当你定义自己版本的移动构造函数或移动赋值运算符的时候,要确保不会抛出异常,并在声明中明确加上noexcept声明。

如果您感兴趣这方面的内容,请在微信上搜索公众号iShare爱分享或者微信号iTechShare并关注,以便在内容更新时直接向您推送。