【C++编程调试秘籍】| 总结归纳要点

news2024/11/16 20:59:13

文章目录

    • 一、编译器是捕捉缺陷的最好场合
      • 1 如何使用编译器捕捉缺陷
    • 二、在运行时遇见错误该如何处理
      • 1 该输出哪些错误信息
      • 2 执行安全检查则会减低程序效率,该如何处理呢
      • 3 当运行时遇到错误时,该如何处理
    • 四、索引越界
      • 1 动态数组
      • 2 静态数组
      • 3 多维数组
    • 5 指针运算
    • 6 无效的指针、引用和迭代器
    • 七、未初始化的变量
      • 1 初始化的数值
      • 2 未初始化的布尔值
    • 八、内存泄漏
      • 1 引用计数指针
      • 2 作用域指针
      • 3 用智能指针实行所有权
    • 九、解引用NULL指针
    • 十、拷贝构造函数和赋值操作符
    • 十一、避免在析构函数中编写代码
    • 十二、怎样编写一致的比较操作符
    • 十三、使用标准C函数库的错误
    • 十四、基本的测试原则
    • 十五、调试错误的策略
    • 十六、使代码更容易调试

一、编译器是捕捉缺陷的最好场合

原因

  • 可以准确的看到缺陷时发生在哪一行、哪一个文件、以及错误的描述;
  • 节省时间;

1 如何使用编译器捕捉缺陷

  • 可以新增一项安全检查,确保传递给函数的值必须位于指定区域内;

新增安全检查

  • 禁止隐式类型转换,用explicit声明一个接收1个参数的构造函数,并避免使用转换操作符;
  • 使用不同的类来表示不同的数据类型(后续提到);
  • 用typedef来重命名该类型;
    在这里插入图片描述

二、在运行时遇见错误该如何处理

如:打开一个文件不存在或没有权限,或访问链接不可用等,为运行时错误;
为此,我们需要输出错误信息:输出错误的原因,错误的地方,相关的变量值,并采取适当的操作

1 该输出哪些错误信息

使用该宏,当条件为时即可触发,将错误信息输出;

#define ASSERT(condition, msg)	\
	if(!(condition)) {			\
		std::cerr << "in file: "__FILE__; \
		std::cerr << " #" << __LINE__ << ": "; 	\
		std::cerr << msg;				\
		exit(1);\
	}

int main() {
	ASSERT(0 > 1, "test");
}
// 示例:
/**
ASSERT(index < array.size(), "Index " << index << " is out of bounds " << array.size());
输出足够的信息,让该错误信息更有意义;
*/

2 执行安全检查则会减低程序效率,该如何处理呢

  • 我们可以将安全检查分为生产使用和测试使用,当不需要时,则可以将其关闭,从而不降低程序的执行效率;
  • 那么何时使用生产、何时使用测试,需要开发者来决定,需要考虑该处使用安全检查其调用频率执行时间(条件的执行时间)等;
  • 且在初次编写代码时,就需要一同编写安全检查,而不是过后在做补充;
#define TEST_ASSERT_ON	// 若定义就开启

#ifdef TEST_ASSERT_ON
#define TEST_ASSERT(condition, msg) ASSERT(condition, msg)
#else
#define TEST_ASSERT(condition, msg)
#endif

#define ASSERT(condition, msg)	\
	if(!(condition)) {			\
		std::cerr << "in file: "__FILE__; \
		std::cerr << " #" << __LINE__ << ": "; 	\
		std::cerr << msg;				\
		exit(1);\
}

int main() {
	TEST_ASSERT(0 > 1, "test");
}

3 当运行时遇到错误时,该如何处理

  • 终止程序:一般用于测试时,立即中断程序,修改后继续尝试;
  • 抛出异常:在程序实际工作中,不宜将程序直接终止,而是抛出一个异常,在将具体的错误信息交给错误处理函数,并在顶层代码中捕捉该异常,将其记录在日志文件中,不会干扰到其他功能的正常运行;
// 一
#ifdef THROW_EXCEPTION_ON_BUG
	throw std::logic_error("error");
#else
	std::cout << "exit" << std::endl;
	exit(1);
#endif

// 二
int main() {
	try {
		double stock_price{ 100.0 };
		ASSERT(
			0 < stock_price && stock_price <= 1e6,
			"Stock price " << stock_price << " is out of range.");

		stock_price = -1.;
		ASSERT(
			0 < stock_price && stock_price <= 1e6,
			"Stock price " << stock_price << " is out of range.");
	}
	catch (const std::exception& ex) {
		std::cerr << "Exception caught in " << __FILE__
			<< " #" << __LINE__ << ":\n"
			<< ex.what() << std::endl;
	}

	return 0;
}

四、索引越界

  • 不要使用静态动态分配的数组,改用arrayvector模板;
  • 不要使用带[]的new和delete操作符,让vector模板为多个元素分配内存;
  • 继承vector,对需要增加安全检查的进行重写;
  • 对于多维数组,通过()访问元素,并提供越界检查;

1 动态数组

若出现越界的情况,则会返回错误的信息;但对于vector提供了at使用,该方法能够抛出一个out_of_range异常,但会降低代码的效率;
故,我们可以自己捕捉目标改写

namespace scpp {

template <typename T>
class vector : public std::vector<T> {
    public:
        typedef unsigned size_type;    

        explicit vector(size_type n = 0)
            : std::vector<T>(n) { }

        vector(size_type n, const T& value) 
            : std::vector<T>(n, value) { }

        template <class InputIterator> vector(InputIterator first,
                                              InputIterator last)
            : std::vector<T>(first, last) { }

        T& operator[] (size_type index) {
            std::stringstream stringStream;
            SCPP_TEST_ASSERT(index < std::vector<T>::size(),
                             stringStream << "Index " << index 
                             << " must be less than "
                             << std::vector<T>::size());
            return std::vector<T>::operator[](index);
        }

        const T& operator[] (size_type index) const {
            std::stringstream stringStream;
            SCPP_TEST_ASSERT(index < std::vector<T>::size(),
                             stringStream << "Index " << index
                             << " must be less than "
                             << std::vector<T>::size());
            return std::vector<T>::operator[](index);
        }
};
}

vector注意事项

  • 当不断向vector插入数据后,vector插入数据可能变得缓慢,故我们需要预先给vector分配内存;
vector<int> vec(n, 0);	// 具有初始化的内存分配
vector<int> vec;		// 无初始化的内存分配
vec.reserve(n);

从vector中派生新类的注意事项
若基类的析构函数不为虚函数,则在多态使用时则子类的析构函数不会被执行到;

  • 故不对子类中添加任何数据成员;
  • 不使用多态的特性;
class Base{
public:
	~Base();	// 非虚拟
};

class Derived : public Base {
public:
	~Derived() {}	// 非虚拟
};

Base* p = new Derived;
delete p;	// 此时delete调用的是基类的析构,不会调用子类的析构

2 静态数组

  • 静态数组从堆栈上分配内存,而vector是通过new分配,速度较慢些,但最好使用array模板(静态数组);
  • 建议不要使用静态、动态数组而是使用vector或array模板;

3 多维数组

  • 多维一般不使用vector的vector,它需要多次分配内存,是低效的;
  • 采用一维度的数组进行存储,在索引上做处理,通过()对元素进行访问,由于[]操作符只能接受1个参数;
template<typename T>
class matrix {
public:
	typedef unsigned size_type;

	matrix(size_type rows, size_type cols)
		:m_rows(rows), m_cols(cols) {
		ASSERT(m_rows > 0, "rows must be positive");
		ASSERT(m_rows > 0, "cols must be positive");
	}

	matrix(size_type rows, size_type cols, const T& init_val)
		:m_rows(rows), m_cols(cols), m_data(m_row*m_cols, init_val) {
		ASSERT(m_rows > 0, "rows must be positive");
		ASSERT(m_rows > 0, "cols must be positive");
	}

	size_type getRows() const { return m_rows; }
	size_type getCols() const { return m_cols; }

	T& operator() (size_type row, size_type col) {
		return m_data[index(row, col)];
	}

	const T& operator() (size_type row, size_type col) const {
		return m_data[index(row, col)];
	}

private:
	size_type index(size_type row, size_type col) const {
		ASSERT(row < m_rows, "Row " << row << " must be less than " << m_rows);
		ASSERT(col < m_cols, "Col " << col << " must be less than " << m_cols);
		return col * row + col;
	}

private:
	size_type m_rows;
	size_type m_cols;
	std::vector<T> m_datas;
};

5 指针运算

  • 避免使用指针运算,使用vector模板或数组索引;

6 无效的指针、引用和迭代器

#include <iostream>
#include <vector>

int main() {
	std::vector<int> v;
	for (int i = 0; i < 10; ++i) v.push_back(i);

	int* old_vptr = &v[3];
	int& old_ref = v[3];
	std::vector<int>::const_iterator old_iter = v.begin() + 3;

	std::cout << "vptr: " << *old_vptr << " ref: " << old_ref <<  " addr: " << old_vptr << std::endl;

	std::cout << "add elements..." << std::endl;

	for (int i = 0; i < 100; ++i) v.push_back(i*10);

	std::vector<int>::const_iterator new_iter = v.begin() + 3;
	std::cout << "vptr: " << *old_vptr << " ref: " << old_ref << " addr: " << &v[3] << std::endl;

	return 0;
}

在这里插入图片描述

  • 出现上述原因是由于&v[3]的地址发生变化,当vector添加元素超出当前容量时,会重新分配一块内存将当前所有值都移植过去,旧的内存将被销毁;
  • 引用也与指针相同,引用只是解引用的指针;
  • 故任何指针、引用或迭代器在修改后都不应被再使用,即使有些容器其迭代器仍有效;

七、未初始化的变量

  • 对于类的数据成员,不要使用内置类型;
  • 重新定义该类型,还可获取编译时类型安全的优点;

1 初始化的数值

  • 在类中,当添加一个数据成员时,即需要在构造函数中对它进行初始化,若有多个构造函数,而没添加数据成员都需要做修改,则需要做大量的工作;
  • 该初始化只针对内置类型,由于内置类型不会有默认的构造函数将它初始化,而类型string类型,则会被默认初始化;
template<typename T>
class TNumber {
public:
	TNumber(const T& x = 0) : m_data(x) {}

	operator T() const { return m_data; }

	TNumber& operator=(const T& x) {
		m_data = x;
		return *this;
	}

	TNumber operator++(int) {
		TNumber<T> copy(*this);
		++m_data;
		return *this;
	}

	TNumber& operator++() {
		++m_data;
		return *this;
	}

	TNumber& operator+=(T x) {
		m_data += x;
		return *this;
	}

	TNumber& operator-=(T x) {
		m_data -= x;
		return *this;
	}

	TNumber& operator*=(T x) {
		m_data *= x;
		return *this;
	}

	TNumber& operator/=(T x) {
		ASSERT(x!=0, "Attmpt to divide by 0");
		m_data /= x;
		return *this;
	}

	T operator/(T x) {
		ASSERT(x != 0, "Attmpt to divide by 0");
		return m_data / x;
	}
private:
	T m_data;
};

typedef TNumber<int> Int;
...


class A {
public:
	Int m_a;	// 此时即可被初始化
};

2 未初始化的布尔值

class Bool {
public:
	Bool(bool x = false)
		:m_data(x) {}

	operator bool() const { return m_data; }

	Bool& operator=(bool x) {
		m_data = x;
		return *this;
	}

	Bool& operator&=(bool x) {
		m_data &= x;
		return *this;
	}

	Bool& operator |= (bool x) {
		m_data |= x;
		return *this;
	}

private:
	bool m_data;
};


std::ostream& operator<<(std::ostream& os, Bool val) {
	if (val) {
		os << "True";
	}
	else {
		os << "False";
	}

	return os;
}

八、内存泄漏

》》》》》》》》内存泄漏参考链接《《《《《《《《

  • 当分配到新内存是,需要立即把指向该内存的指针赋值给智能指针;

内存泄漏:实际是被分配的内存的所有权丢失;

对象的所有权:删除该内存的责任;

可能出现内存泄漏的原因

  • 分配内存后,没有释放即离开当前作用域;
  • 当前内存地址赋给了其他值;
  • 程序运行时,一直保留不在使用的指针;
  • 循环引用导致得不到释放;
  • 对指针赋值NULL,丢失该对象;

注意事项

  • 使用完毕需要删除这块内存;
  • 释放任务只执行一次;
  • delete时,需要注意是否有带[];

1 引用计数指针

该类型的指针销毁时刻为最后消亡的那个对它进行删除;

》》》》》》》》智能指针参考链接《《《《《《《《
该类型指针存在的问题

  • 不是多线程安全;
  • 内存会在堆上new一个整数,速度相对慢;

2 作用域指针

  • 该指针用于不需要复制指针,只想保证被分配的资源被正确的回收;
  • 该指针没有赋值和拷贝方法,不需要再堆上分配整数对它进行拷贝计数;

3 用智能指针实行所有权

当使用一个函数来获取一个指针时,可能会出现的潜在错误,避免对象的所有权不明确等原因,让函数返回一个智能指针;

std::shared_ptr<MyClass> MyFactoryClass::Create(const Input& inputs);
// or
void MyFactoryClass::Create(const Inputs& inputs, auto_ptr<MyClass>& result);

九、解引用NULL指针

当我们试图解引用NULL指针时,将会导致程序崩溃,为此以下提供一种不删除它所指向对象的半智能指针;

template<typename T>
class Ptr {
public:
	explicit Ptr(T* p = nullptr)
		: m_ptr(p) {}

	T* Get() const { return m_ptr; }

	Ptr<T>& operator=(T* p) {
		m_ptr = p;
		return *this;
	}

	T* operator->() const {
		ASSERT(m_ptr != nullptr, "Attempt to use operator -> on NULL pointer.");
	
		return m_ptr;
	}

	T& operator*() const {
		ASSERT(m_ptr != nullptr, "Attempt to use operator -> on NULL pointer.");
		
		return *m_ptr;
	}

private:
	T* m_ptr;
};

上述指针的特性

  • 它不拥有它所指向的对象的所有权,是相同情况下原始指针的替代品;
  • 它默认被初始化为NULL;
  • 提供了运行时诊断,当它为NULL时,若进行解引用,则可对该行为进行检测;

十、拷贝构造函数和赋值操作符

  • 依赖编译器自动创建的默认版本;
  • 将拷贝构造函数和赋值操作符声明为私有,不提供实现禁止任何复制;
  • 尽量避免编写自己的版本;

十一、避免在析构函数中编写代码

  • 在设计类的时候,尽量将析构函数保持为空函数;

何时需要在析构中编写

  • 在基类中,可能需要声明虚拟析构函数;
  • 需要对数据成员进行一个释放;

比较有实质性的做法可能就是在析构中对类中资源的释放,但该做法可能交给智能指针去处理并不需要交给我们手动处理;

交给智能指针的好处

  • 若在构造中new一块内存时,需要new第二块内存,此处出现错误,若使用智能指针,则内存都会被释放掉;
  • 不需要在类的析构中对它进行清理;

十二、怎样编写一致的比较操作符

  • 即使我们本身不需要比较操作符,但后续其他人在使用时可能需要,特别是将其存储在容器中;
  • 比较操作符:> < <= >= == !=
  • 则,我们编写一个Compare函数,然后使用宏来实现所有比较操作符;

class MyClass {
public:
	int CompareTo(const MyClass& that) const;
public:
#define SCPP_DEFINE_COMPARISON_OPERATORS(Class)		\
	bool operator<(const Class& that) const { return CompareTo(that) < 0; }	\
	bool operator>(const Class& that) const { return CompareTo(that) > 0; }	\
	bool operator<=(const Class& that) const { return CompareTo(that) <= 0; }	\
	bool operator>=(const Class& that) const { return CompareTo(that) >= 0; }	\
	bool operator!=(const Class& that) const { return CompareTo(that) != 0; }	\
	bool operator==(const Class& that) const { return CompareTo(that) == 0; }	\
};


十三、使用标准C函数库的错误

  • 对于strlen(),该用size()方法;
  • 对于strcpy(),可仅仅使用字符串赋值操作符来复制;
  • 对于strcat(),可改用ostringstream;
  • C库中的函数并不安全,甚至速度不及C++类,故应多使用C++类;

十四、基本的测试原则

  • 对每个类都进行所有的测试,且包括对一组类协同作用时的所有测试,还要包括对整个应用程序的测试;
  • 在允许的范围内需要尝试创建一个可重复生成的测试,保证重复测试时能产生相同的结果;
  • 好的设计时每个层次的类只能调用下面层次的类;
  • 对输入代码需要测试正确和不正确的输入,查看其表现;
  • 对于接受可变长度的输入,需要测试其时间复杂度;
  • 算法的是否高效,使用随机法或穷举法进行测试;

十五、调试错误的策略

  • 在打开安全检测时运行代码,试图覆盖所有可能的情况;
  • 若有任何安全检测失败,修正代码返回步骤1;
  • 若进入步骤3,可合理工作;

十六、使代码更容易调试

若代码中的处理方法可以有多种,可以选择在debug中和release中进行区分;

#ifdef DEBUG
	// 处理1
#else 
	// 处理2

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/136666.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

uboot驱动和Linux内核驱动有什么区别?

一、前言 uboot启动后&#xff0c;一些外设如DDR、EMMC、网口、串口、音频、显示等等已经被初始化&#xff0c;为什么Linux内核中还需要写Linux驱动呢&#xff1f; 二、uboot驱动和Linux驱动的区别 1、直观理解 驱动&#xff0c;不仅仅是为了初始化&#xff0c;还实现了一组…

《Linux》1.权限

1.用户 首先介绍一下Linux中的用户概念。Linux下有两种用户&#xff1a;超级用户&#xff08;root&#xff09;&#xff0c;普通用户。 超级用户&#xff1a;可以再linux系统下做任何事情&#xff0c;不受限制 普通用户&#xff1a;在linux下做有限的事情。 超级用户的命令提示…

FTP回复码

FTP回复码由3个数字和对应文本组成 恢复定义为3个数字编码&#xff0c;紧跟一个空格 sp&#xff0c;然后是一行文本&#xff0c;以telnet的换行符表是结束 但是&#xff0c;某些情况下&#xff0c;文本内容太长超过一行&#xff0c;这就需要在第一行和最后一行特殊处理。处理格…

C++模拟实现栈(stack)和队列 (queue)

目录 一、栈&#xff08;stack) 的介绍 二、队列&#xff08;queue) 的介绍 三、容器适配器 二、deque的简单介绍 三、模拟实现stack 3.1 stack.h 3.2 test.cpp 四、模拟实现queue 4.1 queue.h 4.2 test.cpp 一、栈&#xff08;stack) 的介绍 1. stack是一种容器适配…

字符串、内存函数的介绍(13)

目录 1、字符串函数 1、strlen 模拟实现&#xff1a; 2、strcpy 模拟实现&#xff1a; 3、strcat 模拟实现&#xff1a; 4、strcmp 模拟实现&#xff1a; 5、strncpy 6、strncat 7、strncmp 8、strstr 模拟实现&#xff1a; 9、strtok 10、strerror 11、其他字…

回顾2022年5月IB全球统考成绩,这些学校IB成绩非常亮眼

IB大考成绩放榜&#xff0c;全球17&#xff0c;3878名学生在2022年5月的考试中获得文凭课程(DP)和职业课程(CP)的成绩。今年全球640位考生获得满分45分&#xff0c;全球平均分31.98分。以下是部分公布公布成绩的学校&#xff1a; 成都树德中学国际部&#xff1a;在2022年的全球…

电商维权控价方法论

电商经济繁荣发展&#xff0c;品牌销售渠道多样化&#xff0c;带来流量的同时&#xff0c;各种渠道问题也暴露出来&#xff0c;如&#xff0c;低价、侵权……渠道秩序面临着严峻挑战&#xff0c;品牌生命周期也受到了威胁。所以&#xff0c;越来越多的品牌选择维权控价&#xf…

2022年终总结与2023新年展望

前言 时间过得太快了&#xff0c;虽然写博客已经很多年了&#xff0c;但是年终总结一直由于种种原因没有写过&#xff0c;2022年确实是魔幻的一年&#xff0c;不知不觉自己也已经研二了&#xff0c;因为疫情的原因突然放开&#xff0c;提前放假回家&#xff0c;借此机会写一下…

Git(七) - IDEA 集成 GIT

一、配置 Git 忽略文件 &#xff08;1&#xff09;问题1:为什么要忽略他们&#xff1f; 答&#xff1a;与项目的实际功能无关&#xff0c;不参与服务器上部署运行。把它们忽略掉能够屏蔽IDE工具之 间的差异。 &#xff08;2&#xff09;问题2&#xff1a;怎么忽略&#xff1f; …

TP可能用到的函数

说明 该文章来源于同事lu2ker转载至此处&#xff0c;更多文章可参考&#xff1a;https://github.com/lu2ker/ 文章目录说明in_array()filter_var()class_exists()strpos()escapeshellarg()escapeshellcmd()preg_replace()parse_str()无字母数字下划线的webshellstr_replace()e…

GNN algorithm(4): HAN, Heterogeneous Graph Attention Network

目录 background (1) heterogeneity of graph (2) semantic-level attention (3) Node-level attention (4) HAN contributions 2. Related Work 2.1 GNN 2.2 Network Embedding 3. Preliminary background 4. Proposed Model 4.1 Node-level attention ideas: …

Unity脚本(四)

视频教程&#xff1a;https://www.bilibili.com/video/BV12s411g7gU?p149 目录 键盘输入 InputManager 键盘输入 当通过名称指定的按键被用户按住时返回true&#xff1a; bool resultInput.GetKey(KeyCode.A); 当用户按下指定名称按键时的那一帧返回true&#xff1a;…

Python学习笔记——变量和简单数据类型

编码默认情况下&#xff0c;Python 3 源码文件以 UTF-8 编码&#xff0c;所有字符串都是 unicode 字符串。 当然你也可以为源码文件指定不同的编码。标识符第一个字符必须是字母表中字母或下划线 _ 。标识符的其他的部分由字母、数字和下划线组成。标识符对大小写敏感。在 Pyth…

【深度学习】机器学习\深度学习常见相关公开数据集汇总(图像处理相关数据集、自然语言处理相关数据集、语音处理相关数据集)

一、前言 1. 介绍 常来说&#xff0c;深度学习的关键在于实践。从图像处理到语音识别&#xff0c;每一个细分领域都有着独特的细微差别和解决方法。 然而&#xff0c;你可以从哪里获得这些数据呢&#xff1f;现在大家所看到的大部分研究论文都用的是专有数据集&#xff0c;这…

超声波测距传感器认知

目录 一、超声波测距传感器认知 二、从零编程实现超声波测距 三、项目——感应开关盖垃圾桶 1、开发步骤 2、感应开关盖垃圾桶代码测试 一、超声波测距传感器认知 超声波测距模块是用来测量距离的一种产品&#xff0c;通过发送和接收超声波&#xff0c;利用时间差和声音…

【网络】udp_socket编程

目录 1.认识端口号 1.1 理解端口号和进程ID 1.2 理解源端口号和目的端口号 2.认识TCP协议 3.认识UDP协议 4.网络字节序 5.socket编程接口 5.1socket常见API 5.2sockaddr结构 sockaddr结构 sockaddr_in 结构 in_addr结构 6.简单的UDP网络程序 6.1创建套接字 6.2 …

【Docker】三 镜像容器常用命令

这里写目录标题1 配置镜像加速器2 Docker镜像常用命令2.1 搜索镜像2.2 下载镜像[重要]2.3 列出镜像[重要]2.3 删除本地镜像[重要]2.4 保存镜像2.5 加载镜像2.6 构建镜像[重要]3 容器常用命令3.1 新建并启动容器[重要]3.2 列出容器[重要]3.3 停止容器[重要]3.4 强制停止容器[重要…

你可以不用Git,但不能不会Git(三)基础(下)

目录 一.将文件添加至忽略列 二.日志记录操作 三.比较文件差异 四.还原文件 一.将文件添加至忽略列 一般我们总会有些文件无需纳入Git的管理&#xff0c;也不希望它们总出现在未跟踪文件列表。通常都是些自动生成的文件&#xff0c;比如日志文件&#xff0c;或者编译过程中…

重学 Java 设计模式-结构型模式-适配器模式

重学 Java 设计模式-结构型模式-适配器模式 内容摘自&#xff1a;添加链接描述 适配器模式介绍 图片来自&#xff1a;https://refactoringguru.cn/design-patterns/adapter(opens new window) 适配器模式的主要作用就是把原本不兼容的接口&#xff0c;通过适配修改做到统一。…

canva绘制(二次、三次)贝塞尔曲线并且图片在曲线上运动

下图为实现效果&#xff08;图片在三次贝塞尔曲线中运动&#xff09; <!DOCTYPE html> <html lang"en"><head><meta charset"UTF-8"><meta name"viewport" content"widthdevice-width, initial-scale1.0"…