在 C++ 中,创建一个类,即便这个类是空类,也会自动生成下面 6 个默认成员函数。
#include "Stack.h"
#include <iostream>
using namespace std;
int main()
{
Stack st;
st.Init(); // 初始化
// 入栈顺序:1 2 3 4 5
st.Push(1);
st.Push(2);
st.Push(3);
st.Push(4);
st.Push(5);
cout << "当前栈中有效元素个数为:" << st.Size() << endl; // 5
while (!st.Empty())
{
cout << st.Top() << endl;
st.Pop();
}
// 出栈顺序:5 4 3 2 1
st.Destroy(); // 销毁
return 0;
}
我们调用
Init方法对栈进行初始化,但这样的操作依赖于程序员的自觉性以及个人修养。假设一个蹩脚的、初阶的程序员,他没有意识到初始化的重要性,甚至不理解为什么需要初始化,于是没有调用Init方法或者说忘记调用,程序最终就会出现问题。所以需要一种机制(mechanism)来避免上述问题,让程序尽可能正确地运行。
构造函数(constructor)是一个特殊的成员函数,函数名与类名相同,没有返回值,通过类创建对象时由编译器自动调用,保证每个数据成员都有一个合适的初始值,并且在对象的生命周期内只调用一次。
构造函数可以实现重载:
#include <stdlib.h>
#include <iostream>
using namespace std;
typedef int STDataType;
class Stack
{
public:
// 无参的默认构造函数
Stack()
{
_data = (STDataType*)malloc(sizeof(STDataType) * 5);
if (nullptr == _data)
{
perror("initialization failed!");
exit(-1);
}
_top = 0;
_capacity = 5;
cout << "Stack()::Initialization succeeded~" << endl;
}
// 带参的构造函数
Stack(int default_capacity)
{
_data = (STDataType*)malloc(sizeof(STDataType) * default_capacity);
if (nullptr == _data)
{
perror("initialization failed!");
exit(-1);
}
_top = 0;
_capacity = default_capacity;
cout << "Stack(int)::Initialization succeeded~" << endl;
}
private:
STDataType* _data;
int _top;
int _capacity;
};
int main()
{
Stack st1; // 实例化 st1 时,编译器自动调用【默认构造函数】
// Stack()::Initialization succeeded~
Stack st2(4); // 实例化 st2 时,编译器自动调用带参构造函数
// Stack(int)::Initialization succeeded~
// 注意:
// Stack st1; 不能写成 Stack st1();
// 因为后者会和函数声明冲突
return 0;
} 默认构造函数(default constructor)就是在没有显示提供初始化式时调用的构造函数,即如果创建某个类的对象时没有提供初始化式就会调用默认构造函数,例如 Stack st;。
无参的构造函数是默认构造函数,全缺省的构造函数也是默认构造函数。默认构造函数是可以在没有参数的情况下调用的构造函数(A default constructor is one that can be called with no arguments)。
构造函数可以重载,那么在语法上,无参的默认构造函数和全缺省的默认构造函数可以同时出现,但是在实际使用中,会造成一定的问题,例如:
typedef int STDataType;
class Stack
{
public:
// 无参的默认构造函数
Stack()
{
_data = (STDataType*)malloc(sizeof(STDataType) * 5);
if (nullptr == _data)
{
perror("initialization failed!");
exit(-1);
}
_top = 0;
_capacity = 5;
}
// 全缺省的默认构造函数
Stack(int default_capacity = 5)
{
_data = (STDataType*)malloc(sizeof(STDataType) * default_capacity);
if (nullptr == _data)
{
perror("initialization failed!");
exit(-1);
}
_top = 0;
_capacity = default_capacity;
}
private:
STDataType* _data;
int _top;
int _capacity;
};
int main()
{
Stack st1(5); // ok
// Stack st3; // error-->对重载函数的调用不明确
return 0;
} 如果类中没有显示定义构造函数,则 C++ 编译器会自动生成一个无参的默认构造函数,一旦用户显示定义,编译器将不再生成:
typedef int STDataType;
class Stack
{
public:
private:
STDataType* _data;
int _top;
int _capacity;
};
int main()
{
Stack st; // st 实例化时,调用编译器自动生成的无参的默认构造函数
return 0;
}
对上述程序进行调试:
可以发现,编译器自动生成的无参的默认构造函数并没有完成对栈的初始化,栈对象
st的成员变量_data/ _top/ _capacity依旧是随机值。这是因为 C++ 把类型分成了内置类型(基本类型)和自定义类型。内置类型就是语言本身提供的数据类型,例如:
int/ char/ float/ double...;自定义类型就是使用class/ struct/ union等关键字自己定义的类型。对于内置类型的成员变量,编译器自动生成的无参的默认构造函数并不会对其处理;对于自定义类型的成员变量,则会调用其对应的默认构造函数。例如:
#include <iostream> using namespace std; class A { public: A(int x = 10) // 全缺省的默认构造函数 { _i = x; } void Print() { cout << _i << endl; } private: int _i; }; class B { public: void Print() { cout << _j << endl; _a.Print(); } private: int _j; // 内置类型的成员变量 A _a; // 自定义类型的成员变量 }; int main() { B b; b.Print(); // 随机值 --> 说明 B 类中自动生成的默认构造函数没有对 _j 初始化 // 10 --> 说明 B 类中自动生成的默认构造函数调用了 _a 的默认构造函数 return 0; }
在 C++11 中,针对编译器自动生成的无参的默认构造函数不能对内置类型的成员变量初始化的缺陷,打了一个补丁(patch),即内置类型的成员变量在类声明时可以给缺省值:
#include <iostream>
using namespace std;
class Point
{
public:
void Print()
{
cout << "(" << _x << ", " << _y << ")" << endl;
}
private:
int _x = 0;
int _y = 0;
};
int main()
{
Point p;
p.Print(); // (0, 0)
return 0;
} #include "Stack.h"
#include <iostream>
using namespace std;
int main()
{
Stack st;
st.Init(); // 初始化
// 入栈顺序:1 2 3 4 5
st.Push(1);
st.Push(2);
st.Push(3);
st.Push(4);
st.Push(5);
cout << "当前栈中有效元素个数为:" << st.Size() << endl; // 5
while (!st.Empty())
{
cout << st.Top() << endl;
st.Pop();
}
// 出栈顺序:5 4 3 2 1
st.Destroy(); // 销毁
return 0;
}
在 C++ 中,清理和初始化一样重要,但是比起初始化,程序员更加容易忽视清理,关于清理的意识和观念是更加淡薄的。同样是上面这个例子,如果我们不调用
Init方法对栈进行初始化,程序最终会出现问题,但是我们不调用Destroy方法销毁栈,程序仍然能输出正确的结果,且正常退出。但隐藏的问题就是,如果在其他情况下,不进行销毁,可能会造成内存泄漏,所以此时 需要另一种机制来避免上述问题。
析构函数(destructor)也是一个特殊的成员函数,函数名是类名前面加上 ~,没有参数也没有返回值,对象生命周期结束时,编译器会自动调用析构函数。
#include <stdlib.h>
#include <iostream>
using namespace std;
typedef int STDataType;
class Stack
{
public:
// 默认构造函数
Stack(int default_capacity = 5)
{
_data = (STDataType*)malloc(sizeof(STDataType) * default_capacity);
if (nullptr == _data)
{
perror("initialization failed!");
exit(-1);
}
_top = 0;
_capacity = default_capacity;
cout << "initialization succeeded~" << endl;
}
// 默认析构函数
~Stack()
{
free(_data);
_data = nullptr;
_top = _capacity = 0;
cout << "cleanup completed~" << endl;
}
private:
STDataType* _data;
int _top;
int _capacity;
};
int main()
{
cout << "before opening brace" << endl;
{
Stack st;
}
cout << "after closing brace" << endl;
// before opening brace
// initialization succeeded~
// cleanup completed~
// after closing brace
return 0;
}
析构函数不能重载,一个类中只能有一个析构函数。若类中没有显示定义,C++ 编译器会自动生成默认的析构函数。
和编译器自动生成的默认构造函数类似,对于内置类型的成员变量,默认的析构函数并不会对其处理;对于自定义类型的成员变量,则会调用其对应的析构函数:
#include <iostream>
using namespace std;
class A
{
public:
A(int x = 10)
{
_i = x;
}
~A()
{
cout << "~A()" << endl;
}
void Print()
{
cout << _i << endl;
}
private:
int _i;
};
class B
{
public:
B(int x = 20) //
{
_j = x;
}
void Print()
{
cout << _j << endl;
_a.Print();
}
private:
int _j;
A _a;
};
int main()
{
B b;
b.Print();
// 20
// 10 --> 说明也自动调用了 _a 的默认构造函数
// ~A() --> 说明自动调用了 _a 的析构函数
return 0;
} class Stack
{
public:
Stack(int default_capacity = 5)
{
_data = (int*)malloc(sizeof(int) * default_capacity);
if (nullptr == _data)
{
perror("initialization failed!");
exit(-1);
}
_top = 0;
_capacity = default_capacity;
}
~Stack()
{
free(_data);
_data = nullptr;
_top = _capacity = 0;
}
bool empty()
{
return _top == 0;
}
void push(const int& x)
{
if (_top == _capacity)
{
int* tmp = (int*)malloc(sizeof(int) * 2 * _capacity);
if (nullptr == tmp)
{
perror("realloc failed!");
return;
}
_data = tmp;
_capacity *= 2;
}
_data[_top++] = x;
}
void pop()
{
assert(!empty());
--_top;
}
int top()
{
assert(!empty());
return _data[_top - 1];
}
int size()
{
return _top;
}
private:
int* _data;
int _top;
int _capacity;
};
/* -------------------------------------------------------------- */
class MyQueue
{
public:
// 由于 MyQueue 类的两个成员变量都是自定义类型,
// 所以不需要写构造函数,也不需要写析构函数,
// 直接使用编译器自动生成的默认构造函数和析构函数即可。
bool empty()
{
return _pushSt.empty() && _popSt.empty();
}
void push(int x)
{
_pushSt.push(x);
}
int peek()
{
assert(!empty()); // 前提是队列非空
if (_popSt.empty())
{
while (!_pushSt.empty())
{
_popSt.push(_pushSt.top());
_pushSt.pop();
}
}
return _popSt.top();
}
int pop()
{
int ret = peek();
_popSt.pop();
return ret;
}
private:
Stack _pushSt; // 用于入队的栈
Stack _popSt; // 用于出队的栈
};
拷贝构造函数是一种特殊的构造函数,通过类创建对象时,它是使用同一类中之前创建的对象来初始化这个新创建的对象。
拷贝构造函数的参数只有一个且必须是同一类对象的引用(常用 const 修饰),把对象作为函数参数,编译器则会报错,因为会引发无穷递归调用:
#include <iostream>
using namespace std;
class Date
{
public:
Date(int year = 1949, int month = 10, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
Date(const Date& d) // 拷贝构造函数
{
cout << "Date(cosnt Date& d)" << endl;
_year = d._year;
_month = d._month;
_day = d._day;
}
void Display()
{
cout << _year << "-" << _month << "-" << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1(2023, 5, 1);
Date d2(d1); // 实例化 d2 时,编译器自动调用拷贝构造函数
// Date(cosnt Date& d)
d2.Display();
// 2023-5-1
return 0;
}
情形一(对象作为函数参数):
void func(Date d) { // Date(cosnt Date& d) d.Dispaly(); // 2023-5-1 } // 对象作为函数参数时,会创建一个 Date 类的对象(形参), // 并调用拷贝构造函数初始化这个对象 int main() { Date d(2023, 5, 1); func(d); return 0; }所以如果拷贝构造函数允许把对象作为函数参数,那么就会引发无穷递归:
情形二(对象作为函数返回值):
Date func() { Date d(2023, 5, 1); return d; } // 对象作为函数返回值时,也会创建一个 Date 类的临时对象返回, // 并调用拷贝构造函数初始化这个对象 int main() { Date ret = func(); // Date(cosnt Date& d) ret.Dispaly(); // 2023-5-1 return 0; }
若类中没有显示定义,C++ 编译器会自动生成默认的拷贝构造函数。对于内置类型的成员变量,默认的拷贝构造函数对其进行浅拷贝(也叫值拷贝);对于自定义类型的成员变量,则是调用其对应的拷贝构造函数:
#include <iostream>
using namespace std;
class A
{
public:
A(int x = 10)
{
_i = x;
}
A(const A& a)
{
cout << "A(cosnt A& a)" << endl;
_i = a._i;
}
void Print()
{
cout << _i << endl;
}
private:
int _i;
};
class B
{
public:
B(int x = 20)
{
_j = x;
}
void Print()
{
cout << _j << endl;
_a.Print();
}
private:
int _j;
A _a;
};
int main()
{
B b1;
B b2(b1);
// A(const A&a)
b2.Print();
// 20
// 10
return 0;
} 类中如果没有涉及资源申请,可以直接使用编译器自动生成的默认拷贝构造函数;而一旦涉及到资源申请,则需要显示地实现拷贝构造函数,进行深拷贝,例如:
typedef int STDataType;
class Stack
{
public:
Stack(int default_capacity = 5)
{
_data = (STDataType*)malloc(sizeof(STDataType) * default_capacity);
if (nullptr == _data)
{
perror("initialization failed!");
exit(-1);
}
_top = 0;
_capacity = default_capacity;
}
~Stack()
{
free(_data);
_data = nullptr;
_top = _capacity = 0;
}
private:
STDataType* _data;
int _top;
int _capacity;
};
对于 Stack 类,若不显示实现拷贝构造函数,直接使用默认的拷贝构造函数,就会出现下面的问题:
栈
st1和st2的成员变量_data指向同一块动态开辟的内存空间,这就造成其中任意一个栈的改变也会改变另一个栈。并且由于栈
st1和st2的成员变量_data指向同一块动态开辟的内存空间,当st2先销毁时(因为栈的特点是后进先出,注意:这里的栈指的是 main 函数栈帧),编译器自动调用st2的析构函数,将那块动态开辟的内存空间给释放掉了,所以当st1后销毁时,编译器自动调用st1的析构函数,就会对同一块内存空间进行两次释放。所以在这种情况下,需要显示实现拷贝构造函数,进行深拷贝:
#include <stdlib.h> #include <string.h> #include <iostream> using namespace std; typedef int STDataType; class Stack { public: Stack(int default_capacity = 5) { _data = (STDataType*)malloc(sizeof(STDataType) * default_capacity); if (nullptr == _data) { perror("initialization failed!"); exit(-1); } _top = 0; _capacity = default_capacity; } Stack(const Stack& st) // 拷贝构造函数(实现深拷贝) { _data = (STDataType*)malloc(sizeof(STDataType) * st._capacity); if (nullptr == _data) { perror("malloc failed!"); return; } memcpy(_data, st._data, sizeof(STDataType) * st._top); _top = st._top; _capacity = st._capacity; } ~Stack() { cout << "this->_data = " << this->_data << endl; free(_data); _data = nullptr; _top = _capacity = 0; } private: STDataType* _data; int _top; int _capacity; }; int main() { Stack st1; // this->_data = 01285438 Stack st2(st1); // this->_data = 01285BC8 // 说明 st1 和 st2 的成员变量 _data 不再指向同一块动态开辟的内存空间了 return 0; }
拷贝构造函数通常用于:
通过使用另一个同类型的对象来初始化新创建的对象
对象作为函数参数
对象作为函数返回值
C++ 中预定义的运算符的操作对象只能是基本数据类型,但实际上,对于许多用户自定义类型。也需要类似的运算操作,这时就必须在 C++ 中重新定义这些运算符,赋予已有运算符新的功能,使它能够用于特定类型执行特定操作。
重载的运算符是带有特殊名称的函数,函数名是由关键字 operator 和其后要重载的运算符符号构造的。与其他函数一样,重载运算符有一个返回类型和一个参数列表。
运算符重载时要遵循以下规则:
除了类属关系运算符 .、成员指针运算符 .*、作用域运算符 ::、sizeof 运算符和三目运算符 ?: 以外,C++ 中的所有运算符都可以重载。
重载运算符在 C++ 语言中已有的运算符范围内的允许重载的运算符中,不能创建新的运算符。
运算符重载实质上是函数重载,因此编译程序对运算符重载的选择,遵循函数重载的选择。
重载之后的运算符不能改变运算符的优先级和结合性,也不能改变运算符操作数的个数及语法结构。
运算符重载不能改变该运算符用于内置类型对象的含义。它只能和用户自定义类型的对象一起使用,或者用于自定义类型对象和内置类型对象的混合使用。
运算符重载是针对新类型数据的实际需要对原有运算符进行的适当的改造,重载的功能应当与原有功能相类似,避免没有目的地使用运算符重载。
双目运算符重载如果写在类外面,那么是需要两个参数的。
#include <iostream>
using namespace std;
class Date
{
friend bool operator==(const Date& d1, const Date& d2);
// 友元提供了一种突破封装的方式(友元相关的内容会在后面的博客中详解)
public:
Date(int year = 1949, int month = 10, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
private:
int _year;
int _month;
int _day;
};
bool operator==(const Date& d1, const Date& d2)
{
return d1._year == d2._year
&& d1._month == d2._month
&& d1._day == d2._day;
}
int main()
{
Date d1(2023, 5, 1);
Date d2(2023, 6, 1);
if (d1 == d2)
cout << "d1 == d2" << endl;
else
cout << "d1 != d2" << endl;
// d1 != d2
return 0;
}
d1 == d2等价于operator==(d1, d2)。
双目运算符重载如果写在类里面(即为类的成员函数),只能显示说明一个参数,该形参是运算符的右操作数。
#include <iostream>
using namespace std;
class Date
{
public:
Date(int year = 1949, int month = 10, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
bool operator==(const Date& d)
{
return this->_year == d._year
&& this->_month == d._month
&& this->_day == d._day;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1(2023, 5, 1);
Date d2(2023, 6, 1);
if (d1 == d2)
cout << "d1 == d2" << endl;
else
cout << "d1 != d2" << endl;
// d1 != d2
return 0;
}
d1 == d2等价于d1.operator==(d2)。
前置单目运算符重载为类的成员函数时,不需要显示地说明参数。
后置单目运算符重载为类的成员函数时,函数要带一个整型形参,但在调用函数时,不需要传递实参,因为编译器会自动传递。
#include <iostream>
using namespace std;
class Date
{
public:
Date(int year = 1949, int month = 10, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
// 拷贝构造函数
Date(const Date& d)
{
cout << "Date(const Date& d)" << endl;
_year = d._year;
_month = d._month;
_day = d._day;
}
// 前置 ++ 运算符重载
Date& operator++()
{
_day += 1;
return *this;
}
// 后置 ++ 运算符重载
Date operator++(int) // 不能返回临时对象的引用
{
Date tmp = *this;
_day += 1;
return tmp;
}
void Display()
{
cout << _year << "-" << _month << "-" << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1(2023, 5, 1);
Date d2(2023, 5, 1);
Date ret1 = ++d1; // 先 ++,后使用
Date ret2 = d2++; // 先使用,后 ++
// Date(const Date& d)
// Date(const Date& d)
// Date(const Date& d)
ret1.Display();
d1.Display();
// 2023-5-2
// 2023-5-2
ret2.Display();
d2.Display();
// 2023-5-1
// 2023-5-2
return 0;
}
注意:
简单地将
_day加 1 是存在一定问题的,如果_day大于当前这个月的总天数时,还需要修改_month,甚至需要修改_year。这个问题留在后面的 Date 类的实现中解决。一定要弄清楚初始化(initialization)和赋值(assignment)之间的区别。
Date ret1 = ++d1;、Date ret2 = d2++;和Date tmp = *this都是初始化,而不是赋值,它们等价于Date ret1(++d1);、Date ret2(d2++);和Date tmp(*this);,所以调用拷贝构造函数,而非赋值运算符重载(后面会讲解)。
#include <iostream>
using namespace std;
class Date
{
public:
Date(int year = 1949, int month = 10, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
Date& operator=(const Date& d) // 返回引用既可以提高效率,同时支持连续赋值
{
cout << "Date& operator=(const Date& d)" << endl;
if (this != &d)
{
_year = d._year;
_month = d._month;
_day = d._day;
}
return *this;
}
void Display()
{
cout << _year << "-" << _month << "-" << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1(2023, 5, 1);
Date d2(2023, 6, 1);
Date d3(2023, 7, 1);
d2.Display();
d3.Display();
// 2023-6-1
// 2023-7-1
d3 = d2 = d1;
// Date& operator=(const Date& d)
// Date& operator=(const Date& d)
d2.Display();
d3.Display();
// 2023-5-1
// 2023-5-1
return 0;
}
特性:
赋值运算符只能重载成类的成员函数,不能重载成全局函数(对于其他的编译器会自动生成的默认成员函数也一样)。因为如果类中没有显示定义,C++ 编译器会自定生成一个默认的赋值运算符重载,此时用户再在类外自己定义一个全局的赋值运算符重载,就会和默认的赋值运算符重载冲突了。
和编译器自动生成的默认的拷贝构造函数类似,对于内置类型的成员变量,默认的赋值运算符重载对其进行浅拷贝;对于自定义类型的成员变量,则是调用其对应的赋值运算符重载:
#include <iostream>
using namespace std;
class A
{
public:
A(int x = 10) { _i = x; }
void Print()
{
cout << _i << endl;
}
A& operator=(const A& a)
{
cout << "A& operator=(const A& a)" << endl;
if (this != &a)
{
_i = a._i;
}
return *this;
}
private:
int _i;
};
class B
{
public:
B(int x = 20) { _j = x; }
void Print()
{
cout << _j << endl;
_a.Print();
}
private:
int _j;
A _a;
};
int main()
{
B b1;
B b2(200);
b1.Print();
// 20
// 10
b1 = b2;
// A& operator=(const A& a)
b1.Print();
// 200
// 10
return 0;
} 如果类中没有涉及到资源申请,可以直接使用编译器自动生成的默认赋值运算符重载;而一旦涉及到资源申请,则需要显示地实现,进行深拷贝。
const 成员函数面向对象程序设计中,为了体现封装,通常不允许直接修改类对象的数据成员,若要修改,应调用公有成员函数来完成。为了保证 const 对象的常量性,编译器须区分不安全与安全的成员函数,即区分试图修改类对象与不修改类对象的函数。例如:
#include <iostream>
using namespace std;
class Date
{
public:
Date(int year = 1949, int month = 10, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
void Display()
{
cout << _year << "-" << _month << "-" << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1(2023, 5, 1);
d1.Display(); // 2023-5-1
const Date d2(2023, 6, 1);
// d2.Display(); // error
return 0;
}
在 C++ 中,只有被声明为
const的成员函数才能被一个const类对象调用,所以上面定义的 Display 成员函数不是const成员函数,被认为是不安全的成员函数。要声明一个
const类型的类成员函数,只需要在成员函数参数列表后加上关键字const,例如:void Display() const { cout << _year << "-" << _month << "-" << _day << endl; }
const修饰类成员函数,实际上修饰的是该成员函数隐含的 this 指针,因此const成员函数不能修改类中的数据成员。
const 取地址运算符重载这两个运算符一般不需要重载,使用编译器默认生成的重载即可,只有特殊情况,才需要重载,例如想让别人获取到指定的内容。
#include <iostream>
uing namespace std;
class Date
{
public:
Date(int year = 1949, int month = 10, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
Date* operator&()
{
cout << "Date* operator&()" << endl;
return this;
}
const Date* operator&() const
{
cout << "const Date* operator&() const" << endl;
return this;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1;
cout << &d1 << endl;
// Date* operator&()
// 地址 1
const Date d2;
cout << &d2 << endl;
// const Date* operator&() const
// 地址 2
return 0;
}
因篇幅问题不能全部显示,请点此查看更多更全内容
Copyright © 2019- aiwanbo.com 版权所有 赣ICP备2024042808号-3
违法及侵权请联系:TEL:199 18 7713 E-MAIL:2724546146@qq.com
本站由北京市万商天勤律师事务所王兴未律师提供法律服务